From 6388e76f85ce1c325055d6e27d2e220c32379b6f Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 28 May 2026 16:07:18 +0200 Subject: [PATCH 01/13] test: migrate trivial doctrine behat features to ApiTestCase Drop six behat scenarios in favor of PHPUnit ApiTestCase: - handle_links.feature -> tests/Functional/Doctrine/LinkHandlerTest.php - issue5722/subresource_without_get.feature -> tests/Functional/SubResource/SubResourceWithoutGetTest.php - issue6175/standard_put_entity_inheritence -> tests/Functional/Doctrine/MappedSuperclassPutTest.php - eager_loading.feature -> tests/Functional/Doctrine/EagerLoadingTest.php - separated_resource.feature -> tests/Functional/Doctrine/SeparatedResourceTest.php - multiple_filter.feature -> tests/Functional/Doctrine/MultipleFilterTest.php EagerLoadingTest reads ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager::\$dql to assert the produced DQL, mirroring behat's "the DQL should be equal to" step. --- features/doctrine/eager_loading.feature | 99 ------ features/doctrine/handle_links.feature | 17 - .../issue5722/subresource_without_get.feature | 8 - .../standard_put_entity_inheritence.feature | 25 -- features/doctrine/multiple_filter.feature | 48 --- features/doctrine/separated_resource.feature | 116 ------- .../Functional/Doctrine/EagerLoadingTest.php | 300 ++++++++++++++++++ tests/Functional/Doctrine/LinkHandlerTest.php | 74 +++++ .../Doctrine/MappedSuperclassPutTest.php | 62 ++++ .../Doctrine/MultipleFilterTest.php | 89 ++++++ .../Doctrine/SeparatedResourceTest.php | 146 +++++++++ .../SubResource/SubResourceWithoutGetTest.php | 64 ++++ 12 files changed, 735 insertions(+), 313 deletions(-) delete mode 100644 features/doctrine/eager_loading.feature delete mode 100644 features/doctrine/handle_links.feature delete mode 100644 features/doctrine/issue5722/subresource_without_get.feature delete mode 100644 features/doctrine/issue6175/standard_put_entity_inheritence.feature delete mode 100644 features/doctrine/multiple_filter.feature delete mode 100644 features/doctrine/separated_resource.feature create mode 100644 tests/Functional/Doctrine/EagerLoadingTest.php create mode 100644 tests/Functional/Doctrine/LinkHandlerTest.php create mode 100644 tests/Functional/Doctrine/MappedSuperclassPutTest.php create mode 100644 tests/Functional/Doctrine/MultipleFilterTest.php create mode 100644 tests/Functional/Doctrine/SeparatedResourceTest.php create mode 100644 tests/Functional/SubResource/SubResourceWithoutGetTest.php diff --git a/features/doctrine/eager_loading.feature b/features/doctrine/eager_loading.feature deleted file mode 100644 index ee7e73ff6a1..00000000000 --- a/features/doctrine/eager_loading.feature +++ /dev/null @@ -1,99 +0,0 @@ -@!mongodb -Feature: Eager Loading - In order to have better performance - As a client software developer - The eager loading should be enabled - - @createSchema - Scenario: Eager loading for a relation - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies/1" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a1, relatedToDummyFriend_a3, fourthLevel_a2, dummyFriend_a4 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - LEFT JOIN o.thirdLevel thirdLevel_a1 - LEFT JOIN thirdLevel_a1.fourthLevel fourthLevel_a2 - LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a3 - LEFT JOIN relatedToDummyFriend_a3.dummyFriend dummyFriend_a4 - WHERE o.id = :id_p1 - """ - - Scenario: Eager loading for the search filter - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.level=3" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o - INNER JOIN o.relatedDummy relatedDummy_a1 - INNER JOIN relatedDummy_a1.thirdLevel thirdLevel_a2 - WHERE o IN( - SELECT o_a3 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o_a3 - INNER JOIN o_a3.relatedDummy relatedDummy_a4 - INNER JOIN relatedDummy_a4.thirdLevel thirdLevel_a5 - WHERE thirdLevel_a5.level = :level_p1 - ) - ORDER BY o.id ASC - """ - - Scenario: Eager loading for a relation and a search filter - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies?relatedToDummyFriend.dummyFriend=2" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, fourthLevel_a5, dummyFriend_a6 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1 - LEFT JOIN o.thirdLevel thirdLevel_a4 - LEFT JOIN thirdLevel_a4.fourthLevel fourthLevel_a5 - INNER JOIN relatedToDummyFriend_a1.dummyFriend dummyFriend_a6 - WHERE o IN( - SELECT o_a2 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o_a2 - INNER JOIN o_a2.relatedToDummyFriend relatedToDummyFriend_a3 - WHERE relatedToDummyFriend_a3.dummyFriend = :dummyFriend_p1 - ) - ORDER BY o.id ASC - """ - - Scenario: Eager loading for a relation and a property filter with multiple relations - Given there is a dummy travel - When I send a "GET" request to "/dummy_travels/1?properties[]=confirmed&properties[car][]=brand&properties[passenger][]=nickname" - Then the response status code should be 200 - And the JSON node "confirmed" should be equal to "true" - And the JSON node "car.carBrand" should be equal to "DummyBrand" - And the JSON node "passenger.nickname" should be equal to "Tom" - And the DQL should be equal to: - """ - SELECT o, car_a1, passenger_a2 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel o - LEFT JOIN o.car car_a1 - LEFT JOIN o.passenger passenger_a2 - WHERE o.id = :id_p1 - """ - - Scenario: Eager loading for a relation with complex sub-query filter - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies?complex_sub_query_filter=1" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a3, relatedToDummyFriend_a5, fourthLevel_a4, dummyFriend_a6 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - LEFT JOIN o.thirdLevel thirdLevel_a3 - LEFT JOIN thirdLevel_a3.fourthLevel fourthLevel_a4 - LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a5 - LEFT JOIN relatedToDummyFriend_a5.dummyFriend dummyFriend_a6 - WHERE o.id IN ( - SELECT related_dummy_a1.id - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy related_dummy_a1 - INNER JOIN related_dummy_a1.relatedToDummyFriend related_to_dummy_friend_a2 - WITH related_to_dummy_friend_a2.name = :name_p1 - ) - ORDER BY o.id ASC - """ diff --git a/features/doctrine/handle_links.feature b/features/doctrine/handle_links.feature deleted file mode 100644 index ebfa7b10e4f..00000000000 --- a/features/doctrine/handle_links.feature +++ /dev/null @@ -1,17 +0,0 @@ -Feature: Use a link handler to retrieve a resource - - @createSchema - Scenario: Get collection - Given there are a few link handled dummies - When I send a "GET" request to "/link_handled_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @createSchema - Scenario: Get item - Given there are a few link handled dummies - When I send a "GET" request to "/link_handled_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "slug" should be equal to "foo" diff --git a/features/doctrine/issue5722/subresource_without_get.feature b/features/doctrine/issue5722/subresource_without_get.feature deleted file mode 100644 index ff54949e926..00000000000 --- a/features/doctrine/issue5722/subresource_without_get.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: Get a subresource from inverse side that has no item operation - - @!mongodb - @createSchema - Scenario: Get a subresource from inverse side that has no item operation - Given there are logs on an event - When I send a "GET" request to "/events/03af3507-271e-4cca-8eee-6244fb06e95b/logs" - Then the response status code should be 200 diff --git a/features/doctrine/issue6175/standard_put_entity_inheritence.feature b/features/doctrine/issue6175/standard_put_entity_inheritence.feature deleted file mode 100644 index 07d0d7e88cb..00000000000 --- a/features/doctrine/issue6175/standard_put_entity_inheritence.feature +++ /dev/null @@ -1,25 +0,0 @@ -Feature: Update properties of a resource that are inherited with standard PUT operation - - @!mongodb - @createSchema - Scenario: Update properties of a resource that are inherited with standard PUT operation - Given there is a dummy entity with a mapped superclass - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mapped_subclasses/1" with body: - """ - { - "foo": "updated value" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyMappedSubclass", - "@id": "/dummy_mapped_subclasses/1", - "@type": "DummyMappedSubclass", - "id": 1, - "foo": "updated value" - } - """ diff --git a/features/doctrine/multiple_filter.feature b/features/doctrine/multiple_filter.feature deleted file mode 100644 index d98e36cf264..00000000000 --- a/features/doctrine/multiple_filter.feature +++ /dev/null @@ -1,48 +0,0 @@ -Feature: Multiple filters on collections - In order to retrieve large collections of filtered resources - As a client software developer - I need to retrieve collections filtered by multiple parameters - - @createSchema - Scenario: Get collection filtered by multiple parameters - Given there are 30 dummy objects with dummyDate and dummyBoolean true - And there are 20 dummy objects with dummyDate and dummyBoolean false - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28&dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=1&dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - diff --git a/features/doctrine/separated_resource.feature b/features/doctrine/separated_resource.feature deleted file mode 100644 index 90ba193fd68..00000000000 --- a/features/doctrine/separated_resource.feature +++ /dev/null @@ -1,116 +0,0 @@ -Feature: Use state options to use an entity that is not a resource - In order to work with resources and a doctrine entity - As a client software developer - I need to retrieve a CRUD by specifying an entity class - - @!mongodb - @createSchema - Scenario: Get collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SeparatedEntity"}, - "@id": {"pattern": "^/separated_entities"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object" - } - }, - "hydra:totalItems": {"type":"number"}, - "hydra:view": { - "type": "object" - } - } - } - """ - - @!mongodb - @createSchema - Scenario: Get ordered collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities?order[value]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:member[0].value" should be equal to "5" - - @!mongodb - @createSchema - Scenario: Get item - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @!mongodb - @createSchema - Scenario: Get all EntityClassAndCustomProviderResources - Given there are 1 separated entities - When I send a "GET" request to "/entityClassAndCustomProviderResources" - Then the response status code should be 200 - - @!mongodb - @createSchema - Scenario: Get one EntityClassAndCustomProviderResource - Given there are 1 separated entities - When I send a "GET" request to "/entityClassAndCustomProviderResources/1" - Then the response status code should be 200 - - @mongodb - @createSchema - Scenario: Get collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SeparatedDocument"}, - "@id": {"pattern": "^/separated_documents"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object" - } - }, - "hydra:totalItems": {"type":"number"}, - "hydra:view": { - "type": "object" - } - } - } - """ - - @mongodb - @createSchema - Scenario: Get ordered collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents?order[value]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:member[0].value" should be equal to "5" - - @mongodb - @createSchema - Scenario: Get item - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/tests/Functional/Doctrine/EagerLoadingTest.php b/tests/Functional/Doctrine/EagerLoadingTest.php new file mode 100644 index 00000000000..d6f7168b8a0 --- /dev/null +++ b/tests/Functional/Doctrine/EagerLoadingTest.php @@ -0,0 +1,300 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPassenger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EagerLoadingTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyFriend::class, + RelatedToDummyFriend::class, + DummyTravel::class, + DummyCar::class, + DummyPassenger::class, + ThirdLevel::class, + FourthLevel::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + if ($this->isMongoDB()) { + $this->markTestSkipped('Eager loading is ORM only.'); + } + } + + public function testEagerLoadingForARelation(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a1, relatedToDummyFriend_a3, fourthLevel_a2, dummyFriend_a4 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + LEFT JOIN o.thirdLevel thirdLevel_a1 + LEFT JOIN thirdLevel_a1.fourthLevel fourthLevel_a2 + LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a3 + LEFT JOIN relatedToDummyFriend_a3.dummyFriend dummyFriend_a4 +WHERE o.id = :id_p1 +DQL); + } + + public function testEagerLoadingForTheSearchFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class, + ]); + $this->createDummyWithFourthLevelRelation(); + + self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.level=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o + INNER JOIN o.relatedDummy relatedDummy_a1 + INNER JOIN relatedDummy_a1.thirdLevel thirdLevel_a2 +WHERE o IN( + SELECT o_a3 + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o_a3 + INNER JOIN o_a3.relatedDummy relatedDummy_a4 + INNER JOIN relatedDummy_a4.thirdLevel thirdLevel_a5 + WHERE thirdLevel_a5.level = :level_p1 + ) +ORDER BY o.id ASC +DQL); + } + + public function testEagerLoadingForARelationAndSearchFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies?relatedToDummyFriend.dummyFriend=2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, fourthLevel_a5, dummyFriend_a6 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1 + LEFT JOIN o.thirdLevel thirdLevel_a4 + LEFT JOIN thirdLevel_a4.fourthLevel fourthLevel_a5 + INNER JOIN relatedToDummyFriend_a1.dummyFriend dummyFriend_a6 +WHERE o IN( + SELECT o_a2 + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o_a2 + INNER JOIN o_a2.relatedToDummyFriend relatedToDummyFriend_a3 + WHERE relatedToDummyFriend_a3.dummyFriend = :dummyFriend_p1 + ) +ORDER BY o.id ASC +DQL); + } + + public function testEagerLoadingForARelationAndPropertyFilterWithMultipleRelations(): void + { + $this->recreateSchema([ + DummyTravel::class, DummyCar::class, DummyPassenger::class, + ]); + $this->createDummyTravel(); + + $response = self::createClient()->request( + 'GET', + '/dummy_travels/1?properties[]=confirmed&properties[car][]=brand&properties[passenger][]=nickname', + ['headers' => ['Accept' => 'application/ld+json']] + ); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertTrue($data['confirmed']); + $this->assertSame('DummyBrand', $data['car']['carBrand']); + $this->assertSame('Tom', $data['passenger']['nickname']); + $this->assertDqlEquals(<<<'DQL' +SELECT o, car_a1, passenger_a2 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel o + LEFT JOIN o.car car_a1 + LEFT JOIN o.passenger passenger_a2 +WHERE o.id = :id_p1 +DQL); + } + + public function testEagerLoadingForARelationWithComplexSubQueryFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies?complex_sub_query_filter=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a3, relatedToDummyFriend_a5, fourthLevel_a4, dummyFriend_a6 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + LEFT JOIN o.thirdLevel thirdLevel_a3 + LEFT JOIN thirdLevel_a3.fourthLevel fourthLevel_a4 + LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a5 + LEFT JOIN relatedToDummyFriend_a5.dummyFriend dummyFriend_a6 +WHERE o.id IN ( + SELECT related_dummy_a1.id + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy related_dummy_a1 + INNER JOIN related_dummy_a1.relatedToDummyFriend related_to_dummy_friend_a2 + WITH related_to_dummy_friend_a2.name = :name_p1 + ) +ORDER BY o.id ASC +DQL); + } + + private function createRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + + $manager->persist($relation); + } + + $relatedDummy2 = new RelatedDummy(); + $relatedDummy2->setName('RelatedDummy without friends'); + $manager->persist($relatedDummy2); + $manager->flush(); + $manager->clear(); + } + + private function createDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + $manager->clear(); + } + + private function createDummyTravel(): void + { + $manager = $this->getManager(); + + $car = new DummyCar(); + $car->setName('model x'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + + $passenger = new DummyPassenger(); + $passenger->nickname = 'Tom'; + $manager->persist($passenger); + + $travel = new DummyTravel(); + $travel->car = $car; + $travel->passenger = $passenger; + $travel->confirmed = true; + $manager->persist($travel); + + $manager->flush(); + $manager->clear(); + } + + private function assertDqlEquals(string $expected): void + { + $actual = EntityManager::$dql; + $expected = preg_replace('/\(\R */', '(', $expected); + $expected = preg_replace('/\R *\)/', ')', $expected); + $expected = preg_replace('/\R */', ' ', $expected); + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Functional/Doctrine/LinkHandlerTest.php b/tests/Functional/Doctrine/LinkHandlerTest.php new file mode 100644 index 00000000000..8f52ba454da --- /dev/null +++ b/tests/Functional/Doctrine/LinkHandlerTest.php @@ -0,0 +1,74 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\LinkHandledDummy as LinkHandledDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LinkHandledDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class LinkHandlerTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [LinkHandledDummy::class]; + } + + public function testGetCollectionFiltersBySlugViaLinksHandler(): void + { + $resource = $this->isMongoDB() ? LinkHandledDummyDocument::class : LinkHandledDummy::class; + $this->recreateSchema([$resource]); + + $manager = $this->getManager(); + foreach (['foo', 'bar', 'baz', 'foz'] as $slug) { + $manager->persist(new $resource($slug)); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/link_handled_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testGetItemReturnsSlug(): void + { + $resource = $this->isMongoDB() ? LinkHandledDummyDocument::class : LinkHandledDummy::class; + $this->recreateSchema([$resource]); + + $manager = $this->getManager(); + foreach (['foo', 'bar', 'baz', 'foz'] as $slug) { + $manager->persist(new $resource($slug)); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/link_handled_dummies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('foo', $response->toArray()['slug']); + } +} diff --git a/tests/Functional/Doctrine/MappedSuperclassPutTest.php b/tests/Functional/Doctrine/MappedSuperclassPutTest.php new file mode 100644 index 00000000000..80088c63df6 --- /dev/null +++ b/tests/Functional/Doctrine/MappedSuperclassPutTest.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\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMappedSubclass; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MappedSuperclassPutTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyMappedSubclass::class]; + } + + public function testStandardPutOnEntityInheritedFromMappedSuperclass(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema([DummyMappedSubclass::class]); + + $manager = $this->getManager(); + $manager->persist(new DummyMappedSubclass()); + $manager->flush(); + + $response = self::createClient()->request('PUT', '/dummy_mapped_subclasses/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'updated value'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/DummyMappedSubclass', + '@id' => '/dummy_mapped_subclasses/1', + '@type' => 'DummyMappedSubclass', + 'id' => 1, + 'foo' => 'updated value', + ]); + } +} diff --git a/tests/Functional/Doctrine/MultipleFilterTest.php b/tests/Functional/Doctrine/MultipleFilterTest.php new file mode 100644 index 00000000000..787615cebf3 --- /dev/null +++ b/tests/Functional/Doctrine/MultipleFilterTest.php @@ -0,0 +1,89 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MultipleFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + public function testCollectionFilteredByDateAndBoolean(): void + { + $resource = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30, true); + $this->createDummies($resource, 20, false); + + $response = self::createClient()->request('GET', '/dummies?dummyDate[after]=2015-04-28&dummyBoolean=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + + $data = $response->toArray(); + $this->assertSame('/contexts/Dummy', $data['@context']); + $this->assertSame('/dummies', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertCount(2, $data['hydra:member']); + + $ids = array_map(static fn (array $item): string => $item['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/28', '/dummies/29'], $ids); + + $this->assertSame('hydra:PartialCollectionView', $data['hydra:view']['@type']); + $this->assertSame('/dummies?dummyBoolean=1&dummyDate%5Bafter%5D=2015-04-28', $data['hydra:view']['@id']); + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb, bool $bool): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyBoolean($bool); + + if ($nb !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + + $manager->persist($dummy); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/SeparatedResourceTest.php b/tests/Functional/Doctrine/SeparatedResourceTest.php new file mode 100644 index 00000000000..b19cd54d433 --- /dev/null +++ b/tests/Functional/Doctrine/SeparatedResourceTest.php @@ -0,0 +1,146 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EntityClassAndCustomProviderResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ResourceWithSeparatedEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResourceOdm\ResourceWithSeparatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SeparatedEntity as SeparatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SeparatedEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SeparatedResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + ResourceWithSeparatedEntity::class, + ResourceWithSeparatedDocument::class, + EntityClassAndCustomProviderResource::class, + ]; + } + + public function testGetCollection(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents' : '/separated_entities'; + $shortName = $this->isMongoDB() ? 'SeparatedDocument' : 'SeparatedEntity'; + + $response = self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/'.$shortName, $data['@context']); + $this->assertStringStartsWith($uri, $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertIsArray($data['hydra:member']); + $this->assertIsInt($data['hydra:totalItems']); + $this->assertArrayHasKey('hydra:view', $data); + } + + public function testGetOrderedCollection(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents' : '/separated_entities'; + + $response = self::createClient()->request('GET', $uri.'?order[value]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertSame('5', $response->toArray()['hydra:member'][0]['value']); + } + + public function testGetItem(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents/1' : '/separated_entities/1'; + + self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testGetAllEntityClassAndCustomProviderResources(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('EntityClassAndCustomProviderResource uses ORM stateOptions only.'); + } + + $this->recreateSchema([SeparatedEntity::class]); + $this->createSeparatedEntities(SeparatedEntity::class, 1); + + self::createClient()->request('GET', '/entityClassAndCustomProviderResources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + public function testGetOneEntityClassAndCustomProviderResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('EntityClassAndCustomProviderResource uses ORM stateOptions only.'); + } + + $this->recreateSchema([SeparatedEntity::class]); + $this->createSeparatedEntities(SeparatedEntity::class, 1); + + self::createClient()->request('GET', '/entityClassAndCustomProviderResources/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + /** + * @param class-string $resource + */ + private function createSeparatedEntities(string $resource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $entity = new $resource(); + $entity->value = (string) $i; + $manager->persist($entity); + } + $manager->flush(); + } +} diff --git a/tests/Functional/SubResource/SubResourceWithoutGetTest.php b/tests/Functional/SubResource/SubResourceWithoutGetTest.php new file mode 100644 index 00000000000..cc8e42880f4 --- /dev/null +++ b/tests/Functional/SubResource/SubResourceWithoutGetTest.php @@ -0,0 +1,64 @@ + + * + * 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\SubResource; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\Event; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\ItemLog; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Ramsey\Uuid\Uuid; + +final class SubResourceWithoutGetTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Event::class, ItemLog::class]; + } + + public function testGetSubresourceFromInverseSideWithoutItemOperation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema([Event::class, ItemLog::class]); + + $manager = $this->getManager(); + $event = new Event(); + $event->logs = new ArrayCollection([new ItemLog(), new ItemLog()]); + $event->uuid = Uuid::fromString('03af3507-271e-4cca-8eee-6244fb06e95b'); + $manager->persist($event); + foreach ($event->logs as $log) { + $log->item = $event; + $manager->persist($log); + } + $manager->flush(); + + self::createClient()->request('GET', '/events/03af3507-271e-4cca-8eee-6244fb06e95b/logs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } +} From 801afb1125472bdc29558770e9fd421706a5af8b Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 28 May 2026 16:24:01 +0200 Subject: [PATCH 02/13] test: migrate doctrine filter behat features to ApiTestCase Drop seven behat filter scenarios in favor of PHPUnit ApiTestCase: - boolean_filter.feature -> tests/Functional/Doctrine/BooleanFilterTest.php - exists_filter.feature -> tests/Functional/Doctrine/ExistsFilterTest.php - numeric_filter.feature -> tests/Functional/Doctrine/NumericFilterTest.php - range_filter.feature -> tests/Functional/Doctrine/RangeFilterTest.php - date_filter.feature -> tests/Functional/Doctrine/DateFilterTest.php - order_filter.feature -> tests/Functional/Doctrine/OrderFilterTest.php - search_filter.feature -> tests/Functional/Doctrine/SearchFilterTest.php Scenario outlines map to PHPUnit's #[TestWith] attribute. Postgres-only scenarios are env-gated via \$this->isSqlite()/isPostgres() checks. --- features/doctrine/boolean_filter.feature | 525 -------- features/doctrine/date_filter.feature | 636 ---------- features/doctrine/exists_filter.feature | 223 ---- features/doctrine/numeric_filter.feature | 219 ---- features/doctrine/order_filter.feature | 824 ------------- features/doctrine/range_filter.feature | 506 -------- features/doctrine/search_filter.feature | 1066 ----------------- .../Functional/Doctrine/BooleanFilterTest.php | 264 ++++ tests/Functional/Doctrine/DateFilterTest.php | 385 ++++++ .../Functional/Doctrine/ExistsFilterTest.php | 201 ++++ .../Functional/Doctrine/NumericFilterTest.php | 146 +++ tests/Functional/Doctrine/OrderFilterTest.php | 314 +++++ tests/Functional/Doctrine/RangeFilterTest.php | 123 ++ .../Functional/Doctrine/SearchFilterTest.php | 802 +++++++++++++ 14 files changed, 2235 insertions(+), 3999 deletions(-) delete mode 100644 features/doctrine/boolean_filter.feature delete mode 100644 features/doctrine/date_filter.feature delete mode 100644 features/doctrine/exists_filter.feature delete mode 100644 features/doctrine/numeric_filter.feature delete mode 100644 features/doctrine/order_filter.feature delete mode 100644 features/doctrine/range_filter.feature delete mode 100644 features/doctrine/search_filter.feature create mode 100644 tests/Functional/Doctrine/BooleanFilterTest.php create mode 100644 tests/Functional/Doctrine/DateFilterTest.php create mode 100644 tests/Functional/Doctrine/ExistsFilterTest.php create mode 100644 tests/Functional/Doctrine/NumericFilterTest.php create mode 100644 tests/Functional/Doctrine/OrderFilterTest.php create mode 100644 tests/Functional/Doctrine/RangeFilterTest.php create mode 100644 tests/Functional/Doctrine/SearchFilterTest.php diff --git a/features/doctrine/boolean_filter.feature b/features/doctrine/boolean_filter.feature deleted file mode 100644 index e3332eea7bd..00000000000 --- a/features/doctrine/boolean_filter.feature +++ /dev/null @@ -1,525 +0,0 @@ -Feature: Boolean filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections with boolean value - - @createSchema - Scenario: Get collection by dummyBoolean true - Given there are 15 dummy objects with dummyBoolean true - And there are 10 dummy objects with dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by dummyBoolean true - When I send a "GET" request to "/dummies?dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/16$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/16$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by embeddedDummy.dummyBoolean true - Given there are 15 embedded dummy objects with embeddedDummy.dummyBoolean true - And there are 10 embedded dummy objects with embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by embeddedDummy.dummyBoolean true - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/16$"}, - {"pattern": "^/embedded_dummies/17$"}, - {"pattern": "^/embedded_dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/16$"}, - {"pattern": "^/embedded_dummies/17$"}, - {"pattern": "^/embedded_dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by association with embed relatedDummy.embeddedDummy.dummyBoolean true - Given there are 15 embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean true - And there are 10 embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?relatedDummy.embeddedDummy.dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/26$"}, - {"pattern": "^/embedded_dummies/27$"}, - {"pattern": "^/embedded_dummies/28$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?relatedDummy.embeddedDummy\\.dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection filtered by non valid properties - When I send a "GET" request to "/dummies?unknown=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 25 - - When I send a "GET" request to "/dummies?unknown=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 25 - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedBoolean objects - When I send a "GET" request to "/converted_booleans?name_converted=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedBoolean"}, - "@id": {"pattern": "^/converted_booleans"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_booleans/(2|4)$"}, - "@type": {"pattern": "^ConvertedBoolean"}, - "name_converted": {"type": "boolean"}, - "id": {"type": "integer", "minimum":2, "maximum": 4} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_booleans\\?name_converted=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_booleans\\{\\?name_converted\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/date_filter.feature b/features/doctrine/date_filter.feature deleted file mode 100644 index 7d8d1a906f8..00000000000 --- a/features/doctrine/date_filter.feature +++ /dev/null @@ -1,636 +0,0 @@ -Feature: Date filter on collections - In order to retrieve large collections of resources filtered by date - As a client software developer - I need to retrieve collections filtered by date - - @createSchema - Scenario: Get collection filtered by date - Given there are 30 dummy objects with dummyDate - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 5, "maximum": 5}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"] - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28T00:00:00%2B00:00" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-28T00%3A00%3A00%2B00%3A00$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05Z" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 5, "maximum": 5}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05Z&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"] - } - } - } - """ - - Scenario: Search for entities within a range - # The order should not influence the search - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05&dummyDate[after]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/5$"} - }, - "required": ["@id"] - }, - "minItems": 1, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05&dummyDate%5Bafter%5D=2015-04-05$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-05&dummyDate[before]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/5$"} - }, - "required": ["@id"] - }, - "minItems": 1, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-05&dummyDate%5Bbefore%5D=2015-04-05$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - Scenario: Search for entities within an impossible range - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-06&dummyDate[before]=2015-04-04" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-06&dummyDate%5Bbefore%5D=2015-04-04$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - Scenario: Get collection filtered by association date - Given there are 30 dummy objects with dummyDate and relatedDummy - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28&relatedDummy_dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28&relatedDummy_dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28T00:00:00%2B00:00" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28T00%3A00%3A00%2B00%3A00$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by association date - Given there are 2 dummy objects with dummyDate and relatedDummy - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:totalItems": {"type":"number", "maximum": 0}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by date that is not a datetime - Given there are 30 dummydate objects with dummyDate - When I send a "GET" request to "/dummy_dates?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the JSON node "hydra:totalItems" should be equal to 3 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null after - Given there are 3 dummydate objects with nullable dateIncludeNullAfter - When I send a "GET" request to "/dummy_dates?dateIncludeNullAfter[after]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullAfter" should be equal to "2015-04-02T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullAfter" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullAfter[before]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullAfter" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullAfter" should be equal to "2015-04-02T00:00:00+00:00" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null before - Given there are 3 dummydate objects with nullable dateIncludeNullBefore - When I send a "GET" request to "/dummy_dates?dateIncludeNullBefore[before]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBefore" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBefore" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullBefore[after]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBefore" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBefore" should be equal to "2015-04-02T00:00:00+00:00" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null before and after - Given there are 3 dummydate objects with nullable dateIncludeNullBeforeAndAfter - When I send a "GET" request to "/dummy_dates?dateIncludeNullBeforeAndAfter[before]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBeforeAndAfter" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBeforeAndAfter" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullBeforeAndAfter[after]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBeforeAndAfter" should be equal to "2015-04-02T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBeforeAndAfter" should be null - - @createSchema - Scenario: Get collection filtered by date that is an immutable date variant - Given there are 30 dummyimmutabledate objects with dummyDate - When I send a "GET" request to "/dummy_immutable_dates?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the JSON node "hydra:totalItems" should be equal to 3 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @createSchema - Scenario: Get collection filtered by embedded date - Given there are 29 embedded dummy objects with dummyDate and embeddedDummy - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/28$"}, - {"pattern": "^/embedded_dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 30 convertedDate objects - When I send a "GET" request to "/converted_dates?name_converted[strictly_after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedDate"}, - "@id": {"pattern": "^/converted_dates"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_dates/(29|30)$"}, - "@type": {"pattern": "^ConvertedDate"}, - "name_converted": {"type": "string"}, - "id": {"type": "integer", "minimum":29, "maximum": 30} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_dates\\?name_converted%5Bstrictly_after%5D=2015\\-04\\-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_dates\\{\\?.*name_converted\\[before\\],name_converted\\[strictly_before\\],name_converted\\[after\\],name_converted\\[strictly_after\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted(\\[(strictly_)?(before|after)\\])$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 4, - "maxItems": 4, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/exists_filter.feature b/features/doctrine/exists_filter.feature deleted file mode 100644 index e99f2ff445a..00000000000 --- a/features/doctrine/exists_filter.feature +++ /dev/null @@ -1,223 +0,0 @@ -Feature: Exists filter on collections - In order to retrieve large collections of resources - As a client software developer - I need to retrieve collections with properties that exist or not - - @createSchema - Scenario: Get collection where a property does not exist - Given there are 15 dummy objects with dummyBoolean true - When I send a "GET" request to "/dummies?exists[dummyBoolean]=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 0}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BdummyBoolean%5D=0$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection where a property does exist - When I send a "GET" request to "/dummies?exists[dummyBoolean]=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 15, "maximum": 15}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(1|2|3)$"} - }, - "required": ["@id"] - }, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BdummyBoolean%5D=1&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Use exists filter with a empty relation collection - Given there are 3 dummy objects having each 0 relatedDummies - And there are 2 dummy objects having each 3 relatedDummies - When I send a "GET" request to "/dummies?exists[relatedDummies]=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(1|2|3)$"} - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BrelatedDummies%5D=0$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Use exists filter with a non empty relation collection - When I send a "GET" request to "/dummies?exists[relatedDummies]=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(4|5)$"} - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BrelatedDummies%5D=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 4 convertedString objects - When I send a "GET" request to "/converted_strings?exists[name_converted]=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedString"}, - "@id": {"pattern": "^/converted_strings"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_strings/(1|3)$"}, - "@type": {"pattern": "^ConvertedString"}, - "name_converted": {"pattern": "^name#(1|3)$"}, - "id": {"type": "integer", "minimum":1, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_strings\\?exists%5Bname_converted%5D=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_strings\\{\\?exists\\[name_converted\\]\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^exists\\[name_converted\\]$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/numeric_filter.feature b/features/doctrine/numeric_filter.feature deleted file mode 100644 index ec449ae0be7..00000000000 --- a/features/doctrine/numeric_filter.feature +++ /dev/null @@ -1,219 +0,0 @@ -Feature: Numeric filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections with numerical value - - @createSchema - Scenario: Get collection by dummyPrice=9.99 - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice=9.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/9$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice=9.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection by multiple dummyPrice - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[]=9.99&dummyPrice[]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/5$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 6, "maximum": 6}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5B%5D=9.99&dummyPrice%5B%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection by non-numeric dummyPrice=marty - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice=marty" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 20, "maximum": 20}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice=marty"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedInteger objects - When I send a "GET" request to "/converted_integers?name_converted[]=2&name_converted[]=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/(2|3)$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":2, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?name_converted%5B%5D=2&name_converted%5B%5D=3$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*name_converted,name_converted\\[\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"}, - {"pattern": "^order\\[name_converted\\]$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - diff --git a/features/doctrine/order_filter.feature b/features/doctrine/order_filter.feature deleted file mode 100644 index 4d3d5587b97..00000000000 --- a/features/doctrine/order_filter.feature +++ /dev/null @@ -1,824 +0,0 @@ -Feature: Order filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections ordered properties - - @createSchema - Scenario: Get collection ordered in ascending order on an integer property and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?order[id]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bid%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in descending order on an integer property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[id]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/30$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/29$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/28$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bid%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on a string property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[name]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/10$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/11$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in descending order on a string property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[name]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/9$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/8$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/7$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered collection on several property keep the order - # Adding 30 more data with the same name - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?order[name]=desc&order[id]=desc" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/39$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/9$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/38$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on an association and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects with relatedDummy - When I send a "GET" request to "/dummies?order[relatedDummy]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5BrelatedDummy%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on an embedded and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects with embeddedDummy - When I send a "GET" request to "/embedded_dummies?order[embeddedDummy]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?order%5BembeddedDummy%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered by default configured order on a embedded string property and on which order filter has been enabled in whitelist mode with default descending order - When I send a "GET" request to "/embedded_dummies?order[embeddedDummy.dummyName]" - Then the response status code should be 422 - - Scenario: Get collection ordered by a non valid properties and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[alias]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Balias%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[alias]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Balias%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[unknown]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bunknown%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[unknown]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bunknown%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection ordered in descending order on a related property - Given there are 2 dummy objects with relatedDummy - When I send a "GET" request to "/dummies?order[relatedDummy.name]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - } - ], - "additionalItems": false, - "maxItems": 2, - "minItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5BrelatedDummy.name%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 3 convertedInteger objects - When I send a "GET" request to "/converted_integers?order[name_converted]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/3$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":3, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/2$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":2, "maximum": 2} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/1$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":1, "maximum": 1} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - } - ], - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?order%5Bname_converted%5D=desc$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*order\\[name_converted\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^order\\[name_converted\\]$"}, - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - # See https://github.com/api-platform/core/pull/3673 - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 3 convertedInteger objects - When I send a "GET" request to "/converted_integers?order[]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/features/doctrine/range_filter.feature b/features/doctrine/range_filter.feature deleted file mode 100644 index 9a9ec12d074..00000000000 --- a/features/doctrine/range_filter.feature +++ /dev/null @@ -1,506 +0,0 @@ -Feature: Range filter on collections - In order to filter results from large collections of resources - As a client software developer - I need to filter collections by range - - @createSchema - Scenario: Get collection filtered by range (between) - Given there are 30 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[between]=12.99..15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/10$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/18$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/22$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/26$"}, - {"pattern": "^/dummies/27$"}, - {"pattern": "^/dummies/30$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 15, "maximum": 15}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=12.99..15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by range (between the same values) - Given there are 30 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[between]=12.99..12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/10$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 8, "maximum": 8}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=12.99..12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter by range (between) with invalid format - When I send a "GET" request to "/dummies?dummyPrice[between]=9.99..12.99..15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "pattern": "^/dummies/([1-9]|[12][0-9]|30)$" - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 30, "maximum": 30}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=9.99..12.99..15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (less than) - When I send a "GET" request to "/dummies?dummyPrice[lt]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/9$"}, - {"pattern": "^/dummies/13$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/21$"}, - {"pattern": "^/dummies/25$"}, - {"pattern": "^/dummies/29$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 8, "maximum": 8}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Blt%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (less than or equal) - When I send a "GET" request to "/dummies?dummyPrice[lte]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/9$"}, - {"pattern": "^/dummies/10$"}, - {"pattern": "^/dummies/13$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"}, - {"pattern": "^/dummies/21$"}, - {"pattern": "^/dummies/22$"}, - {"pattern": "^/dummies/25$"}, - {"pattern": "^/dummies/26$"}, - {"pattern": "^/dummies/29$"}, - {"pattern": "^/dummies/30$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 16, "maximum": 16}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Blte%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than) - When I send a "GET" request to "/dummies?dummyPrice[gt]=15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/8$"}, - {"pattern": "^/dummies/12$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/24$"}, - {"pattern": "^/dummies/28$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 7, "maximum": 7}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than or equal) - When I send a "GET" request to "/dummies?dummyPrice[gte]=15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/8$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/12$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/24$"}, - {"pattern": "^/dummies/27$"}, - {"pattern": "^/dummies/28$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 14, "maximum": 14}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgte%5D=15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than and less than) - When I send a "GET" request to "/dummies?dummyPrice[gt]=12.99&dummyPrice[lt]=19.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/27$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 7, "maximum": 7}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=12.99&dummyPrice%5Blt%5D=19.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities within an impossible range - When I send a "GET" request to "/dummies?dummyPrice[gt]=19.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:totalItems": {"type": "number", "maximum": 0}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=19.99$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedInteger objects - When I send a "GET" request to "/converted_integers?name_converted[lte]=2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/(1|2)$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":1, "maximum": 2} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?name_converted%5Blte%5D=2$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*name_converted\\[between\\],name_converted\\[gt\\],name_converted\\[gte\\],name_converted\\[lt\\],name_converted\\[lte\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"}, - {"pattern": "^order\\[name_converted\\]$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature deleted file mode 100644 index 50718f81963..00000000000 --- a/features/doctrine/search_filter.feature +++ /dev/null @@ -1,1066 +0,0 @@ -Feature: Search filter on collections - In order to get specific result from a large collections of resources - As a client software developer - I need to search for collections properties - - @createSchema - Scenario: Test ManyToMany with filter on join table - Given there is a RelatedDummy with 4 friends - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/related_dummies?relatedToDummyFriend.dummyFriend=/dummy_friends/4" - Then the response status code should be 200 - And the JSON node "_embedded.item" should have 1 element - And the JSON node "_embedded.item[0].id" should be equal to the number 1 - And the JSON node "_embedded.item[0]._links.relatedToDummyFriend" should have 4 elements - And the JSON node "_embedded.item[0]._embedded.relatedToDummyFriend" should have 4 elements - - @createSchema - Scenario: Test #944 - Given there is a DummyCar entity with related colors - When I send a "GET" request to "/dummy_cars?colors.prop=red" - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyCar", - "@id": "/dummy_cars", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy_cars/1", - "@type": "DummyCar", - "colors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "secondColors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "thirdColors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "uuid": [], - "carBrand": "DummyBrand" - } - ], - "hydra:totalItems": 1, - "hydra:view": { - "@id": "/dummy_cars?colors.prop=red", - "@type": "hydra:PartialCollectionView" - }, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummy_cars{?availableAt[before],availableAt[strictly_before],availableAt[after],availableAt[strictly_after],canSell,foobar[],foobargroups[],foobargroups_override[],colors.prop,colors,colors[],secondColors,secondColors[],thirdColors,thirdColors[],uuid,uuid[],name,brand,brand[]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "availableAt[before]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[strictly_before]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[after]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[strictly_after]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "canSell", - "property": "canSell", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobar[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobargroups[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobargroups_override[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors.prop", - "property": "colors.prop", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors", - "property": "colors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors[]", - "property": "colors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "secondColors", - "property": "secondColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "secondColors[]", - "property": "secondColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "thirdColors", - "property": "thirdColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "thirdColors[]", - "property": "thirdColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "uuid", - "property": "uuid", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "uuid[]", - "property": "uuid", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "brand", - "property": "brand", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "brand[]", - "property": "brand", - "required": false - } - ] - } - } - """ - - @createSchema - Scenario: Search collection by name (partial) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name=my" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name=my"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial) - Given there are 30 embedded dummy objects - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyName=my" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyName=my"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name[]=2&name[]=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/12$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name%5B%5D=2&name%5B%5D=3"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial case insensitive) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?dummy=somedummytest1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "dummy": { - "pattern": "^SomeDummyTest\\d{1,2}$" - } - } - } - } - } - } - """ - - @createSchema - Scenario: Search collection by alias (start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?alias=Ali" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?alias=Ali"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by alias (start multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description[]=Sma&description[]=Not" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description%5B%5D=Sma&description%5B%5D=Not"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @sqlite - @createSchema - Scenario: Search collection by description (word_start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description=smart" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description=smart"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - @sqlite - Scenario: Search collection by description (word_start multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description[]=smart&description[]=so" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description%5B%5D=smart&description%5B%5D=so"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - # note on Postgres compared to sqlite the LIKE clause is case sensitive - @postgres - @createSchema - Scenario: Search collection by description (word_start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description=smart" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/6$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description=smart"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search for entities within an impossible range - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name=MuYm" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name=MuYm$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @sqlite - @createSchema - Scenario: Search for entities with an existing collection route name - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?relatedDummies=dummy_cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array" - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummies=dummy_cars"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search related collection by name - Given there are 3 dummy objects having each 3 relatedDummies - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?relatedDummies.name=RelatedDummy1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "_embedded.item" should have 3 elements - And the JSON node "_embedded.item[0]._links.relatedDummies" should have 3 elements - And the JSON node "_embedded.item[1]._links.relatedDummies" should have 3 elements - And the JSON node "_embedded.item[2]._links.relatedDummies" should have 3 elements - - @createSchema - Scenario: Search by related collection id - Given there are 2 dummy objects having each 2 relatedDummies - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?relatedDummies=3" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "totalItems" should be equal to "1" - And the JSON node "_links.item" should have 1 element - And the JSON node "_links.item[0].href" should be equal to "/dummies/2" - - @createSchema - Scenario: Get collection by id equals 9.99 which is not possible - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?id=9.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=9.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection by id 10 - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?id=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/10$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=10"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection ordered by a non valid properties - When I send a "GET" request to "/dummies?unknown=0" - Given there are 30 dummy objects - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?unknown=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Search at third level - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.level=3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/31$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy.thirdLevel.level=3"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Search at fourth level - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.fourthLevel.level=4" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/31$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy.thirdLevel.fourthLevel.level=4"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection on a property using a name converted - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name_converted=Converted 3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/30$"} - ] - }, - "required": ["@id"] - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name_converted=Converted%203"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/dummies\\{\\?.*name_converted.*}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "additionalItems": true, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - - @createSchema - Scenario: Search collection on a property using a nested name converted - Given there are 30 convertedOwner objects with convertedRelated - When I send a "GET" request to "/converted_owners?name_converted.name_converted=Converted 3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedOwner$"}, - "@id": {"pattern": "^/converted_owners$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/converted_owners/3$"}, - {"pattern": "^/converted_owners/30$"} - ] - }, - "name_converted": { - "oneOf": [ - {"pattern": "^/converted_relateds/3$"}, - {"pattern": "^/converted_relateds/30$"} - ] - }, - "required": ["@id", "name_converted"] - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_owners\\?name_converted.name_converted=Converted%203"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_owners\\{\\?.*name_converted\\.name_converted.*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted\\.name_converted"}, - "property": {"pattern": "^name_converted\\.name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "additionalItems": true, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - @createSchema - Scenario: Search by date (#4128) - Given there are 3 dummydate objects with dummyDate - When I send a "GET" request to "/dummy_dates?dummyDate=2015-04-01" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Custom search filters can use Doctrine Expressions as join conditions - Given there is a dummy object with 3 relatedDummies and their thirdLevel - When I send a "GET" request to "/dummy_resource_with_custom_filter?custom=3" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Search on nested sub-entity that doesn't use "id" as its ORM identifier - Given there is a dummy entity with a sub entity with id "stringId" and name "someName" - When I send a "GET" request to "/dummy_with_subresource?subEntity=/dummy_subresource/stringId" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Filters can use UUIDs - Given there is a group object with uuid "61817181-0ecc-42fb-a6e7-d97f2ddcb344" and 2 users - And there is a group object with uuid "32510d53-f737-4e70-8d9d-58e292c871f8" and 1 users - When I send a "GET" request to "/issue5735/issue5735_users?groups[]=/issue5735/groups/61817181-0ecc-42fb-a6e7-d97f2ddcb344&groups[]=/issue5735/groups/32510d53-f737-4e70-8d9d-58e292c871f8" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 3 diff --git a/tests/Functional/Doctrine/BooleanFilterTest.php b/tests/Functional/Doctrine/BooleanFilterTest.php new file mode 100644 index 00000000000..985fcba3578 --- /dev/null +++ b/tests/Functional/Doctrine/BooleanFilterTest.php @@ -0,0 +1,264 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedBoolean as ConvertedBooleanDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class BooleanFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + EmbeddedDummy::class, + RelatedDummy::class, + ConvertedBoolean::class, + ]; + } + + #[TestWith(['true', 15, ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['1', 15, ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['false', 10, ['/dummies/16', '/dummies/17', '/dummies/18']])] + #[TestWith(['0', 10, ['/dummies/16', '/dummies/17', '/dummies/18']])] + public function testFilterDummiesByBoolean(string $value, int $expectedTotal, array $expectedIds): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 15, true); + $this->createDummies($resource, 10, false); + + $response = self::createClient()->request('GET', '/dummies?dummyBoolean='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/Dummy', $data['@context']); + $this->assertSame('/dummies', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + $this->assertSame('hydra:PartialCollectionView', $data['hydra:view']['@type']); + $this->assertStringContainsString('dummyBoolean='.$value, $data['hydra:view']['@id']); + } + + #[TestWith(['true', 15, ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3']])] + #[TestWith(['1', 15, ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3']])] + #[TestWith(['false', 10, ['/embedded_dummies/16', '/embedded_dummies/17', '/embedded_dummies/18']])] + #[TestWith(['0', 10, ['/embedded_dummies/16', '/embedded_dummies/17', '/embedded_dummies/18']])] + public function testFilterEmbeddedDummiesByEmbeddedBoolean(string $value, int $expectedTotal, array $expectedIds): void + { + $embeddedDummyClass = $this->embeddedDummyClass(); + $embeddableDummyClass = $this->embeddableDummyClass(); + $this->recreateSchema([$embeddedDummyClass]); + $this->createEmbeddedDummies($embeddedDummyClass, $embeddableDummyClass, 15, true); + $this->createEmbeddedDummies($embeddedDummyClass, $embeddableDummyClass, 10, false); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyBoolean='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/EmbeddedDummy', $data['@context']); + $this->assertSame('/embedded_dummies', $data['@id']); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testFilterEmbeddedDummiesByRelatedDummyEmbeddedBoolean(): void + { + $embeddedDummyClass = $this->embeddedDummyClass(); + $embeddableDummyClass = $this->embeddableDummyClass(); + $relatedDummyClass = $this->relatedDummyClass(); + $this->recreateSchema([$embeddedDummyClass, $relatedDummyClass]); + $this->createEmbeddedDummiesWithRelatedDummy($embeddedDummyClass, $embeddableDummyClass, $relatedDummyClass, 15, true); + $this->createEmbeddedDummiesWithRelatedDummy($embeddedDummyClass, $embeddableDummyClass, $relatedDummyClass, 10, false); + + $response = self::createClient()->request('GET', '/embedded_dummies?relatedDummy.embeddedDummy.dummyBoolean=true', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(15, $data['hydra:totalItems']); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + #[TestWith(['0'])] + #[TestWith(['1'])] + public function testCollectionIgnoresUnknownBooleanFilter(string $value): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 15, true); + $this->createDummies($resource, 10, false); + + $response = self::createClient()->request('GET', '/dummies?unknown='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(25, $response->toArray()['hydra:totalItems']); + } + + public function testFilterCollectionUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedBooleanDocument::class : ConvertedBoolean::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = (bool) ($i % 2); + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_booleans?name_converted=false', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_booleans/2', '/converted_booleans/4'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedBoolean', $member['@type']); + $this->assertFalse($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function embeddedDummyClass(): string + { + return $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + } + + /** + * @return class-string + */ + private function embeddableDummyClass(): string + { + return $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb, bool $bool): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyBoolean($bool); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + */ + private function createEmbeddedDummies(string $embeddedClass, string $embeddableClass, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Embedded Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embedded Dummy #'.$i); + $embeddable->setDummyBoolean($bool); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + * @param class-string $relatedClass + */ + private function createEmbeddedDummiesWithRelatedDummy(string $embeddedClass, string $embeddableClass, string $relatedClass, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Embedded Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embedded Dummy #'.$i); + $embeddable->setDummyBoolean($bool); + + $related = new $relatedClass(); + $related->setEmbeddedDummy($embeddable); + + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/DateFilterTest.php b/tests/Functional/Doctrine/DateFilterTest.php new file mode 100644 index 00000000000..eaceb789db6 --- /dev/null +++ b/tests/Functional/Doctrine/DateFilterTest.php @@ -0,0 +1,385 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedDate as ConvertedDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyImmutableDate as DummyImmutableDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class DateFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + EmbeddedDummy::class, + DummyDate::class, + DummyImmutableDate::class, + ConvertedDate::class, + ]; + } + + #[TestWith(['dummyDate[after]=2015-04-28', 2])] + #[TestWith(['dummyDate[before]=2015-04-05', 5])] + #[TestWith(['dummyDate[after]=2015-04-28T00:00:00%2B00:00', 2])] + #[TestWith(['dummyDate[before]=2015-04-05Z', 5])] + #[TestWith(['dummyDate[before]=2015-04-05&dummyDate[after]=2015-04-05', 1])] + #[TestWith(['dummyDate[after]=2015-04-05&dummyDate[before]=2015-04-05', 1])] + #[TestWith(['dummyDate[after]=2015-04-06&dummyDate[before]=2015-04-04', 0])] + public function testDummyDateFilter(string $query, int $expectedTotal): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithDate($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame($expectedTotal, $response->toArray()['hydra:totalItems']); + } + + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28', 3])] + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28&relatedDummy_dummyDate[after]=2015-04-28', 3])] + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28T00:00:00%2B00:00', 3])] + public function testAssociationDateFilter(string $query, int $expectedTotal): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithDateAndRelatedDummy($resource, $relatedResource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame($expectedTotal, $response->toArray()['hydra:totalItems']); + } + + public function testAssociationDateFilterWithEmptyResultSet(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithDateAndRelatedDummy($resource, $relatedResource, 2); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(0, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByDateThatIsNotDatetime(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 30); + + $response = self::createClient()->request('GET', '/dummy_dates?dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByDateIncludeNullAfter(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullAfter'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullAfter[after]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullAfter']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullAfter[before]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullAfter']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][1]['dateIncludeNullAfter']); + } + + public function testCollectionFilteredByDateIncludeNullBefore(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullBefore'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBefore[before]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBefore']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBefore']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBefore[after]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBefore']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][1]['dateIncludeNullBefore']); + } + + public function testCollectionFilteredByDateIncludeNullBeforeAndAfter(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullBeforeAndAfter'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBeforeAndAfter[before]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBeforeAndAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBeforeAndAfter']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBeforeAndAfter[after]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBeforeAndAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBeforeAndAfter']); + } + + public function testCollectionFilteredByImmutableDate(): void + { + $resource = $this->isMongoDB() ? DummyImmutableDateDocument::class : DummyImmutableDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $dummy = new $resource(); + $dummy->dummyDate = new \DateTimeImmutable(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_immutable_dates?dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByEmbeddedDate(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 29; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embeddable #'.$i); + $embeddable->setDummyDate($date); + + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + if (29 !== $i) { + $dummy->setDummyDate($date); + } + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/embedded_dummies/28', '/embedded_dummies/29'], $ids); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedDateDocument::class : ConvertedDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $entity = new $resource(); + $entity->nameConverted = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_dates?name_converted[strictly_after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_dates/29', '/converted_dates/30'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedDate', $member['@type']); + $this->assertIsString($member['name_converted']); + } + + $this->assertSame('hydra:IriTemplate', $data['hydra:search']['@type']); + $this->assertSame('BasicRepresentation', $data['hydra:search']['hydra:variableRepresentation']); + $variables = array_map(static fn (array $m): string => $m['variable'], $data['hydra:search']['hydra:mapping']); + sort($variables); + $this->assertSame([ + 'name_converted[after]', + 'name_converted[before]', + 'name_converted[strictly_after]', + 'name_converted[strictly_before]', + ], $variables); + foreach ($data['hydra:search']['hydra:mapping'] as $mapping) { + $this->assertSame('IriTemplateMapping', $mapping['@type']); + $this->assertSame('name_converted', $mapping['property']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @return class-string + */ + private function dummyDateClass(): string + { + return $this->isMongoDB() ? DummyDateDocument::class : DummyDate::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithDate(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + if ($nb !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesWithDateAndRelatedDummy(string $resource, string $relatedResource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setDummyDate($date); + + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setRelatedDummy($relatedDummy); + if ($nb !== $i) { + $dummy->setDummyDate($date); + } + $manager->persist($relatedDummy); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + */ + private function createDummyDates(string $resource, int $nb, ?string $nullableProperty = null): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $dummy = new $resource(); + $dummy->dummyDate = $date; + if ($nullableProperty) { + $dummy->{$nullableProperty} = 0 === $i % 3 ? null : $date; + } + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/ExistsFilterTest.php b/tests/Functional/Doctrine/ExistsFilterTest.php new file mode 100644 index 00000000000..04bdb592cb9 --- /dev/null +++ b/tests/Functional/Doctrine/ExistsFilterTest.php @@ -0,0 +1,201 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedString as ConvertedStringDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedString; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ExistsFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ConvertedString::class, + ]; + } + + public function testCollectionWhereScalarPropertyDoesNotExist(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithBoolean($resource, 15, true); + + $response = self::createClient()->request('GET', '/dummies?exists[dummyBoolean]=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame(0, $data['hydra:totalItems']); + $this->assertSame([], $data['hydra:member']); + } + + public function testCollectionWhereScalarPropertyDoesExist(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithBoolean($resource, 15, true); + + $response = self::createClient()->request('GET', '/dummies?exists[dummyBoolean]=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(15, $data['hydra:totalItems']); + $this->assertCount(3, $data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/dummies/(1|2|3)$#', $member['@id']); + } + } + + public function testCollectionWithEmptyRelationCollection(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource, $this->relatedDummyClass()]); + $this->createDummiesWithRelated($resource, 3, 0); + $this->createDummiesWithRelated($resource, 2, 3); + + $response = self::createClient()->request('GET', '/dummies?exists[relatedDummies]=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(3, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/1', '/dummies/2', '/dummies/3'], $ids); + } + + public function testCollectionWithNonEmptyRelationCollection(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource, $this->relatedDummyClass()]); + $this->createDummiesWithRelated($resource, 3, 0); + $this->createDummiesWithRelated($resource, 2, 3); + + $response = self::createClient()->request('GET', '/dummies?exists[relatedDummies]=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/4', '/dummies/5'], $ids); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedStringDocument::class : ConvertedString::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 4; ++$i) { + $entity = new $resource(); + $entity->nameConverted = ($i % 2) ? "name#$i" : null; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_strings?exists[name_converted]=true', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_strings/1', '/converted_strings/3'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedString', $member['@type']); + $this->assertMatchesRegularExpression('/^name#(1|3)$/', $member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithBoolean(string $resource, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummyBoolean($bool); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + */ + private function createDummiesWithRelated(string $resource, int $nb, int $nbRelated): void + { + $manager = $this->getManager(); + $relatedDummyClass = $this->relatedDummyClass(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + for ($j = 1; $j <= $nbRelated; ++$j) { + $relatedDummy = new $relatedDummyClass(); + $relatedDummy->setName('RelatedDummy'.$j.$i); + $relatedDummy->setAge((int) ($j.$i)); + $manager->persist($relatedDummy); + $dummy->addRelatedDummy($relatedDummy); + } + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/NumericFilterTest.php b/tests/Functional/Doctrine/NumericFilterTest.php new file mode 100644 index 00000000000..b120b28ea68 --- /dev/null +++ b/tests/Functional/Doctrine/NumericFilterTest.php @@ -0,0 +1,146 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NumericFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class, ConvertedInteger::class]; + } + + public function testCollectionByDummyPrice(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice=9.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(3, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/1', '/dummies/5', '/dummies/9'], $ids); + } + + public function testCollectionByMultipleDummyPrice(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice[]=9.99&dummyPrice[]=12.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(6, $data['hydra:totalItems']); + $this->assertCount(3, $data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/dummies/(1|2|5|6|9|10)$#', $member['@id']); + } + } + + public function testCollectionByNonNumericDummyPriceIsIgnored(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice=marty', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(20, $data['hydra:totalItems']); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?name_converted[]=2&name_converted[]=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_integers/2', '/converted_integers/3'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithPrice(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $prices = ['9.99', '12.99', '15.99', '19.99']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyPrice($prices[($i - 1) % 4]); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/OrderFilterTest.php b/tests/Functional/Doctrine/OrderFilterTest.php new file mode 100644 index 00000000000..a4f6c2473c8 --- /dev/null +++ b/tests/Functional/Doctrine/OrderFilterTest.php @@ -0,0 +1,314 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class OrderFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + EmbeddedDummy::class, + ConvertedInteger::class, + ]; + } + + #[TestWith(['order[id]=asc', ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['order[id]=desc', ['/dummies/30', '/dummies/29', '/dummies/28']])] + #[TestWith(['order[name]=asc', ['/dummies/1', '/dummies/10', '/dummies/11']])] + #[TestWith(['order[name]=desc', ['/dummies/9', '/dummies/8', '/dummies/7']])] + public function testOrderDummies(string $query, array $expectedIds): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testOrderByMultipleProperties(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?order[name]=desc&order[id]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/39', '/dummies/9', '/dummies/38'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByAssociation(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithRelatedDummy($resource, $relatedResource, 30); + + $response = self::createClient()->request('GET', '/dummies?order[relatedDummy]=asc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByEmbedded(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('EmbeddedDummy #'.$i); + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/embedded_dummies?order[embeddedDummy]=asc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByEmbeddedStringWithoutValueReturns422(): void + { + $resource = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $this->recreateSchema([$resource]); + + self::createClient()->request('GET', '/embedded_dummies?order[embeddedDummy.dummyName]', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(422); + } + + #[TestWith(['order[alias]=asc'])] + #[TestWith(['order[alias]=desc'])] + #[TestWith(['order[unknown]=asc'])] + #[TestWith(['order[unknown]=desc'])] + public function testOrderByUnsupportedProperty(string $query): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByRelatedProperty(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithRelatedDummy($resource, $relatedResource, 2); + + $response = self::createClient()->request('GET', '/dummies?order[relatedDummy.name]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/2', '/dummies/1'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?order[name_converted]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/converted_integers/3', '/converted_integers/2', '/converted_integers/1'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + + $this->assertSame('hydra:IriTemplate', $data['hydra:search']['@type']); + $this->assertSame('BasicRepresentation', $data['hydra:search']['hydra:variableRepresentation']); + $this->assertStringMatchesFormat('/converted_integers{?%a}', $data['hydra:search']['hydra:template']); + $variables = array_map(static fn (array $m): string => $m['variable'], $data['hydra:search']['hydra:mapping']); + sort($variables); + $this->assertSame([ + 'name_converted', + 'name_converted[]', + 'name_converted[between]', + 'name_converted[gt]', + 'name_converted[gte]', + 'name_converted[lt]', + 'name_converted[lte]', + 'order[name_converted]', + ], $variables); + foreach ($data['hydra:search']['hydra:mapping'] as $mapping) { + $this->assertSame('IriTemplateMapping', $mapping['@type']); + $this->assertSame('name_converted', $mapping['property']); + } + } + + public function testOrderListSyntaxIsAccepted(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + self::createClient()->request('GET', '/converted_integers?order[]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesWithRelatedDummy(string $resource, string $relatedResource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy #'.$i); + + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($relatedDummy); + + $manager->persist($relatedDummy); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/RangeFilterTest.php b/tests/Functional/Doctrine/RangeFilterTest.php new file mode 100644 index 00000000000..d8da6fda3ee --- /dev/null +++ b/tests/Functional/Doctrine/RangeFilterTest.php @@ -0,0 +1,123 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class RangeFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class, ConvertedInteger::class]; + } + + protected function setUp(): void + { + parent::setUp(); + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 30); + } + + #[TestWith(['dummyPrice[between]=12.99..15.99', 15])] + #[TestWith(['dummyPrice[between]=12.99..12.99', 8])] + #[TestWith(['dummyPrice[between]=9.99..12.99..15.99', 30])] + #[TestWith(['dummyPrice[lt]=12.99', 8])] + #[TestWith(['dummyPrice[lte]=12.99', 16])] + #[TestWith(['dummyPrice[gt]=15.99', 7])] + #[TestWith(['dummyPrice[gte]=15.99', 14])] + #[TestWith(['dummyPrice[gt]=12.99&dummyPrice[lt]=19.99', 7])] + #[TestWith(['dummyPrice[gt]=19.99', 0])] + public function testRangeFilter(string $query, int $expectedTotal): void + { + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?name_converted[lte]=2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_integers/1', '/converted_integers/2'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithPrice(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $prices = ['9.99', '12.99', '15.99', '19.99']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyPrice($prices[($i - 1) % 4]); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/SearchFilterTest.php b/tests/Functional/Doctrine/SearchFilterTest.php new file mode 100644 index 00000000000..a5a42fc3231 --- /dev/null +++ b/tests/Functional/Doctrine/SearchFilterTest.php @@ -0,0 +1,802 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\MainResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\SubResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5648\DummyResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Group; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Component\Uid\Uuid as SymfonyUuid; + +final class SearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyFriend::class, + RelatedToDummyFriend::class, + EmbeddedDummy::class, + ThirdLevel::class, + FourthLevel::class, + DummyCar::class, + DummyCarColor::class, + DummyDate::class, + ConvertedOwner::class, + ConvertedRelated::class, + DummyResource::class, + MainResource::class, + SubResource::class, + DummyWithSubEntity::class, + DummySubEntity::class, + Group::class, + Issue5735User::class, + ]; + } + + public function testManyToManyWithFilterOnJoinTable(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $this->recreateSchema([ + RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(4); + + $response = self::createClient()->request('GET', '/related_dummies?relatedToDummyFriend.dummyFriend=/dummy_friends/4', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(1, $data['_embedded']['item']); + $this->assertSame(1, $data['_embedded']['item'][0]['id']); + $this->assertCount(4, $data['_embedded']['item'][0]['_links']['relatedToDummyFriend']); + $this->assertCount(4, $data['_embedded']['item'][0]['_embedded']['relatedToDummyFriend']); + } + + public function testSearchManyToManyWithRelatedEntity(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('DummyCar/Color is ORM only in this scenario.'); + } + $this->recreateSchema([DummyCar::class, DummyCarColor::class]); + $this->createDummyCarWithColors(); + + $response = self::createClient()->request('GET', '/dummy_cars?colors.prop=red', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummy_cars/1', $data['hydra:member'][0]['@id']); + $this->assertCount(2, $data['hydra:member'][0]['colors']); + $this->assertSame('red', $data['hydra:member'][0]['colors'][0]['prop']); + $this->assertSame('blue', $data['hydra:member'][0]['colors'][1]['prop']); + } + + public function testSearchByNamePartial(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name=my', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchEmbeddedByName(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + $this->createEmbeddedDummies($embeddedClass, $embeddableClass, 30); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyName=my', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByNameMultipleValues(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name[]=2&name[]=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/2', '/dummies/3', '/dummies/12'], $ids); + } + + public function testSearchByDummyCaseInsensitive(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?dummy=somedummytest1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + foreach ($response->toArray()['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('/^SomeDummyTest\d{1,2}$/', $member['dummy']); + } + } + + public function testSearchByAliasStart(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?alias=Ali', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchByDescriptionMultipleStart(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description[]=Sma&description[]=Not', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchByDescriptionWordStartSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific: case-insensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description=smart', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByDescriptionWordStartMultipleSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific: case-insensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description[]=smart&description[]=so', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByDescriptionWordStartPostgres(): void + { + if (!$this->isPostgres()) { + $this->markTestSkipped('Postgres-specific: case-sensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description=smart', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/2', '/dummies/4', '/dummies/6'], $ids); + } + + public function testSearchEmptyResult(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name=MuYm', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame([], $response->toArray()['hydra:member']); + } + + public function testSearchByExistingCollectionRouteNameSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies=dummy_cars', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertIsArray($response->toArray()['hydra:member']); + } + + public function testSearchRelatedCollectionByName(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 3, 3); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies.name=RelatedDummy1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(3, $data['_embedded']['item']); + foreach ($data['_embedded']['item'] as $item) { + $this->assertCount(3, $item['_links']['relatedDummies']); + } + } + + public function testSearchByRelatedCollectionId(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 2, 2); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies=3', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(1, $data['totalItems']); + $this->assertCount(1, $data['_links']['item']); + $this->assertSame('/dummies/2', $data['_links']['item'][0]['href']); + } + + public function testCollectionByIdNonInteger(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?id=9.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $response->toArray()['hydra:member']) + ); + } + + public function testCollectionById(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?id=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('/dummies/10', $data['hydra:member'][0]['@id']); + } + + public function testCollectionFilteredByUnknownProperty(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?unknown=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + + $response = self::createClient()->request('GET', '/dummies?unknown=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchAtThirdLevel(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource, $this->thirdLevelClass(), $this->fourthLevelClass()]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 30, 0); + $this->createDummyWithFourthLevelRelation(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.level=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(['/dummies/31'], array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testSearchAtFourthLevel(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource, $this->thirdLevelClass(), $this->fourthLevelClass()]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 30, 0); + $this->createDummyWithFourthLevelRelation(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.fourthLevel.level=4', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(['/dummies/31'], array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testSearchUsingNameConverter(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name_converted=Converted 3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/3', '/dummies/30'], $ids); + } + + public function testSearchUsingNestedNameConverter(): void + { + $ownerClass = $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class; + $relatedClass = $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class; + $this->recreateSchema([$ownerClass, $relatedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $related = new $relatedClass(); + $related->nameConverted = 'Converted '.$i; + $owner = new $ownerClass(); + $owner->nameConverted = $related; + $manager->persist($related); + $manager->persist($owner); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_owners?name_converted.name_converted=Converted 3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/converted_owners/3', '/converted_owners/30'], $ids); + } + + public function testSearchByDate(): void + { + $resource = $this->isMongoDB() ? DummyDateDocument::class : DummyDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $dummy = new $resource(); + $dummy->dummyDate = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_dates?dummyDate=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testCustomSearchFilterUsingDoctrineExpressions(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Custom Doctrine expression filter is ORM only.'); + } + + $this->recreateSchema([Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]); + $this->createDummyWithRelatedDummiesAndThirdLevel(3); + + $response = self::createClient()->request('GET', '/dummy_resource_with_custom_filter?custom=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testSearchOnSubEntityWithStringIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('DummySubEntity is ORM only.'); + } + + $this->recreateSchema([DummyWithSubEntity::class, DummySubEntity::class]); + $manager = $this->getManager(); + $subEntity = new DummySubEntity('stringId', 'someName'); + $mainEntity = new DummyWithSubEntity(); + $mainEntity->setSubEntity($subEntity); + $mainEntity->setName('main'); + $manager->persist($subEntity); + $manager->persist($mainEntity); + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_with_subresource?subEntity=/dummy_subresource/stringId', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testFiltersCanUseUuids(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Issue5735 fixture is ORM only.'); + } + + $this->recreateSchema([Group::class, Issue5735User::class]); + $manager = $this->getManager(); + + $group1 = new Group(); + $group1->setUuid(SymfonyUuid::fromString('61817181-0ecc-42fb-a6e7-d97f2ddcb344')); + $manager->persist($group1); + for ($i = 0; $i < 2; ++$i) { + $user = new Issue5735User(); + $user->addGroup($group1); + $manager->persist($user); + } + $manager->persist(new Issue5735User()); + + $group2 = new Group(); + $group2->setUuid(SymfonyUuid::fromString('32510d53-f737-4e70-8d9d-58e292c871f8')); + $manager->persist($group2); + $user = new Issue5735User(); + $user->addGroup($group2); + $manager->persist($user); + $manager->persist(new Issue5735User()); + + $manager->flush(); + + $response = self::createClient()->request( + 'GET', + '/issue5735/issue5735_users?groups[]=/issue5735/groups/61817181-0ecc-42fb-a6e7-d97f2ddcb344&groups[]=/issue5735/groups/32510d53-f737-4e70-8d9d-58e292c871f8', + ['headers' => ['Accept' => 'application/ld+json']] + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @return class-string + */ + private function thirdLevelClass(): string + { + return $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class; + } + + /** + * @return class-string + */ + private function fourthLevelClass(): string + { + return $this->isMongoDB() ? FourthLevelDocument::class : FourthLevel::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + */ + private function createEmbeddedDummies(string $embeddedClass, string $embeddableClass, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesEachWithRelatedDummies(string $resource, string $relatedResource, int $nb, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + for ($j = 1; $j <= $nbRelated; ++$j) { + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy'.$j.$i); + $relatedDummy->setAge((int) ($j.$i)); + $manager->persist($relatedDummy); + $dummy->addRelatedDummy($relatedDummy); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function createRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + $manager->persist($relation); + } + $manager->flush(); + $manager->clear(); + } + + private function createDummyCarWithColors(): void + { + $manager = $this->getManager(); + $car = new DummyCar(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + + $color1 = new DummyCarColor(); + $color1->setProp('red'); + $color1->setCar($car); + $manager->persist($color1); + + $color2 = new DummyCarColor(); + $color2->setProp('blue'); + $color2->setCar($car); + $manager->persist($color2); + $manager->flush(); + + $car->setColors(new ArrayCollection([$color1, $color2])); + $manager->persist($car); + $manager->flush(); + } + + private function createDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + + $fourthLevelClass = $this->fourthLevelClass(); + $thirdLevelClass = $this->thirdLevelClass(); + $relatedDummyClass = $this->relatedDummyClass(); + $dummyClass = $this->dummyClass(); + + $fourthLevel = new $fourthLevelClass(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new $thirdLevelClass(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new $relatedDummyClass(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new $relatedDummyClass(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new $dummyClass(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + $manager->clear(); + } + + private function createDummyWithRelatedDummiesAndThirdLevel(int $nb): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setThirdLevel($thirdLevel); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + } + $manager->persist($dummy); + $manager->flush(); + } +} From 32a840a1646aaf8304325c7b20389268b877baf9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 09:24:20 +0200 Subject: [PATCH 03/13] test(graphql): migrate behat features to ApiTestCase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate features/graphql/*.feature (11 files, 147 scenarios) to tests/Functional/GraphQl/*Test.php (140 tests, 100% scenario coverage — 6 authorization scenarios consolidated, 1 introspect-types covered by IntrospectionTest). Add src/GraphQl/Test/GraphQlTestTrait.php as reusable helper providing executeGraphQl(), introspectSchema(), executeGraphQlMultipart() and GraphQL-specific assertions. Drop tests/Behat/GraphqlContext.php and remove the corresponding context entries from behat.yml.dist. Fix stale metadata-cache leakage between test classes: - WithResourcesTrait::invalidateMetadataPools() clears the on-disk pool directories AND PhpFilesAdapter::$valuesCache (process-lifetime static memoisation of append-only pool files — disk clearing alone is not enough because the static cache keeps returning entries computed under a prior class's resource subset). - SetupClassResourcesTrait::tearDownAfterClass calls ensureKernelShutdown so the next class boots a fresh container with the updated resource set and a re-warmed cache. Without this fix, classes that register a superset of a previously-run class's resources receive cached ResourceMetadataCollection entries missing the link/relation entries for the not-yet-registered classes, producing GraphQL responses with null sub-collections. --- behat.yml.dist | 9 - features/graphql/authorization.feature | 576 --------- features/graphql/collection.feature | 1109 ----------------- features/graphql/docs.feature | 10 - features/graphql/filters.feature | 302 ----- features/graphql/input_output.feature | 202 --- features/graphql/introspection.feature | 621 --------- features/graphql/mutation.feature | 1071 ---------------- features/graphql/query.feature | 696 ----------- features/graphql/schema.feature | 113 -- features/graphql/subscription.feature | 224 ---- features/graphql/type.feature | 80 -- src/GraphQl/Test/GraphQlTestTrait.php | 141 +++ tests/Behat/GraphqlContext.php | 178 --- .../Functional/GraphQl/AuthorizationTest.php | 590 +++++++++ tests/Functional/GraphQl/CollectionTest.php | 924 ++++++++++++++ tests/Functional/GraphQl/CustomTypeTest.php | 134 ++ tests/Functional/GraphQl/DocsTest.php | 29 + tests/Functional/GraphQl/FilterTest.php | 528 ++++++++ tests/Functional/GraphQl/InputOutputTest.php | 236 ++++ .../Functional/GraphQl/IntrospectionTest.php | 487 ++++++++ tests/Functional/GraphQl/MutationTest.php | 950 ++++++++++++++ tests/Functional/GraphQl/QueryTest.php | 852 +++++++++++++ tests/Functional/GraphQl/SchemaExportTest.php | 174 +++ tests/Functional/GraphQl/SubscriptionTest.php | 254 ++++ tests/SetupClassResourcesTrait.php | 1 + tests/WithResourcesTrait.php | 45 + 27 files changed, 5345 insertions(+), 5191 deletions(-) delete mode 100644 features/graphql/authorization.feature delete mode 100644 features/graphql/collection.feature delete mode 100644 features/graphql/docs.feature delete mode 100644 features/graphql/filters.feature delete mode 100644 features/graphql/input_output.feature delete mode 100644 features/graphql/introspection.feature delete mode 100644 features/graphql/mutation.feature delete mode 100644 features/graphql/query.feature delete mode 100644 features/graphql/schema.feature delete mode 100644 features/graphql/subscription.feature delete mode 100644 features/graphql/type.feature create mode 100644 src/GraphQl/Test/GraphQlTestTrait.php delete mode 100644 tests/Behat/GraphqlContext.php create mode 100644 tests/Functional/GraphQl/AuthorizationTest.php create mode 100644 tests/Functional/GraphQl/CollectionTest.php create mode 100644 tests/Functional/GraphQl/CustomTypeTest.php create mode 100644 tests/Functional/GraphQl/DocsTest.php create mode 100644 tests/Functional/GraphQl/FilterTest.php create mode 100644 tests/Functional/GraphQl/InputOutputTest.php create mode 100644 tests/Functional/GraphQl/IntrospectionTest.php create mode 100644 tests/Functional/GraphQl/MutationTest.php create mode 100644 tests/Functional/GraphQl/QueryTest.php create mode 100644 tests/Functional/GraphQl/SchemaExportTest.php create mode 100644 tests/Functional/GraphQl/SubscriptionTest.php diff --git a/behat.yml.dist b/behat.yml.dist index a771434c534..fb84483e88d 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -4,7 +4,6 @@ default: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' @@ -39,7 +38,6 @@ postgres: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' @@ -59,7 +57,6 @@ mongodb: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' @@ -79,7 +76,6 @@ mercure: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' @@ -98,7 +94,6 @@ default-coverage: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' @@ -118,7 +113,6 @@ mongodb-coverage: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' @@ -138,7 +132,6 @@ mercure-coverage: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' @@ -156,7 +149,6 @@ legacy: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' @@ -190,7 +182,6 @@ symfony_listeners: contexts: - 'ApiPlatform\Tests\Behat\CommandContext' - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - 'ApiPlatform\Tests\Behat\JsonContext' - 'ApiPlatform\Tests\Behat\HydraContext' - 'ApiPlatform\Tests\Behat\HttpCacheContext' diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature deleted file mode 100644 index f1e918b5242..00000000000 --- a/features/graphql/authorization.feature +++ /dev/null @@ -1,576 +0,0 @@ -Feature: Authorization checking - In order to use the GraphQL API - As a client software user - I need to be authorized to access a given resource. - - @createSchema - Scenario: An anonymous user tries to retrieve a secured item - Given there are 1 SecuredDummy objects - When I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - title - description - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummy" should be null - - Scenario: An anonymous user tries to retrieve a secured collection - Given there are 1 SecuredDummy objects - When I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummies" should be null - - Scenario: An admin can retrieve a secured collection - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummies" should exist - And the JSON node "data.securedDummies" should not be null - - Scenario: An anonymous user cannot retrieve a secured collection - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummies" should be null - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummies" should be null - - Scenario: An anonymous user tries to create a resource they are not allowed to - When I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { - securedDummy { - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Only admins can create a secured dummy." - And the JSON node "data.createSecuredDummy" should be null - - @createSchema - Scenario: An admin can access a secured collection relation - Given there are 1 SecuredDummy objects owned by admin with related dummies - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummies" should have 1 element - - Scenario: An admin can access a secured relation - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummy" should exist - And the JSON node "data.securedDummy.relatedDummy" should not be null - - @createSchema - Scenario: A user can't access a secured collection relation - Given there are 1 SecuredDummy objects owned by dunglas with related dummies - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummies" should be null - - Scenario: A user can't access a secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummy" should be null - - Scenario: A user can't access a secured relation resource directly - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - relatedSecuredDummy(id: "/related_secured_dummies/1") { - id - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.relatedSecuredDummy" should be null - - Scenario: A user can't access a secured relation resource collection directly - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - relatedSecuredDummies { - edges { - node { - id - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.relatedSecuredDummies" should be null - - Scenario: A user can access a secured collection relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedSecuredDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedSecuredDummies" should have 1 element - - Scenario: A user can access a secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedSecuredDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedSecuredDummy" should exist - And the JSON node "data.securedDummy.relatedSecuredDummy" should not be null - - Scenario: A user can access a non-secured collection relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - publicRelatedSecuredDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.publicRelatedSecuredDummies" should have 1 element - - Scenario: A user can access a non-secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - When I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - publicRelatedSecuredDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.publicRelatedSecuredDummy" should exist - And the JSON node "data.securedDummy.publicRelatedSecuredDummy" should not be null - - @createSchema - Scenario: An admin can create a secured resource - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.owner" should be equal to "someone" - - Scenario: An admin can create another secured resource - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.owner" should be equal to "dunglas" - - Scenario: An admin can create a secured resource with an owner-only property if they will be the owner - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "admin", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "it works"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should be equal to the string "it works" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - ownerOnlyProperty - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummies.edges[2].node.ownerOnlyProperty" should be equal to "it works" - - Scenario: An admin can't create a secured resource with an owner-only property if they won't be the owner - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "should not be set"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should exist - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should be null - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/4") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummy.ownerOnlyProperty" should be equal to "" - - Scenario: A user cannot retrieve an item they doesn't own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - owner - title - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummy" should be null - - Scenario: A user can retrieve an item they owns - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.owner" should be equal to the string "dunglas" - - Scenario: An admin can see a secured admin-only property on an object they don't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - adminOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.adminOnlyProperty" should exist - And the JSON node "data.securedDummy.adminOnlyProperty" should not be null - - Scenario: A user can't see a secured admin-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - adminOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.adminOnlyProperty" should be null - - Scenario: A user can see a secured owner-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.ownerOnlyProperty" should exist - And the JSON node "data.securedDummy.ownerOnlyProperty" should not be null - - Scenario: A user can update a secured owner-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/2", ownerOnlyProperty: "updated"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateSecuredDummy.securedDummy.ownerOnlyProperty" should be equal to the string "updated" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummy.ownerOnlyProperty" should be equal to the string "updated" - - Scenario: An admin can't see a secured owner-only property on an object they don't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.ownerOnlyProperty" should be null - - Scenario: A user can't assign to themself an item they doesn't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "kitten"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.updateSecuredDummy" should be null - - Scenario: A user can update an item they owns and transfer it - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/2", owner: "vincent"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateSecuredDummy.securedDummy.owner" should be equal to the string "vincent" diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature deleted file mode 100644 index afc2dc097ec..00000000000 --- a/features/graphql/collection.feature +++ /dev/null @@ -1,1109 +0,0 @@ -Feature: GraphQL collection support - - @createSchema - Scenario: Retrieve a collection through a GraphQL query - Given there are 4 dummy objects with relatedDummy and its thirdLevel - When I send the following GraphQL request: - """ - { - dummies { - ...dummyFields - } - } - fragment dummyFields on DummyCursorConnection { - edges { - node { - id - name - relatedDummy { - name - thirdLevel { - id - level - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[2].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummy.name" should be equal to "RelatedDummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummy.thirdLevel.level" should be equal to 3 - - @createSchema - Scenario: Retrieve an nonexistent collection through a GraphQL query - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - } - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - And the JSON node "data.dummies.pageInfo.endCursor" should be null - And the JSON node "data.dummies.pageInfo.startCursor" should be null - And the JSON node "data.dummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be false - - @createSchema - Scenario: Retrieve a collection with a nested collection through a GraphQL query - Given there are 4 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - relatedDummies { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[2].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy23" - - @createSchema - Scenario: Retrieve a collection with a nested collection (inverse side) through a GraphQL query - Given there is a video game with music groups - When I send the following GraphQL request: - """ - { - musicGroups { - edges { - node { - name - videoGames { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.musicGroups.edges[0].node.name" should be equal to "Sum 41" - And the JSON node "data.musicGroups.edges[0].node.videoGames.edges[0].node.name" should be equal to "Guitar Hero" - And the JSON node "data.musicGroups.edges[1].node.name" should be equal to "Franz Ferdinand" - And the JSON node "data.musicGroups.edges[1].node.videoGames.edges[0].node.name" should be equal to "Guitar Hero" - - @createSchema - Scenario: Retrieve a collection and an item through a GraphQL query - Given there are 3 dummy objects with dummyDate - And there are 2 dummy group objects - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - dummyDate - } - } - } - dummyGroup(id: "/dummy_groups/2") { - foo - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].node.dummyDate" should be equal to "2015-04-02" - And the JSON node "data.dummyGroup.foo" should be equal to "Foo #2" - - @createSchema - Scenario: Retrieve a specific number of items in a collection through a GraphQL query - Given there are 4 dummy objects - When I send the following GraphQL request: - """ - { - dummies(first: 2) { - edges { - node { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 2 elements - - @createSchema - Scenario: Retrieve a specific number of items in a nested collection through a GraphQL query - Given there are 2 dummy objects having each 5 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(first: 1) { - edges { - node { - name - relatedDummies(first: 2) { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - - @createSchema - Scenario: Paginate through collections through a GraphQL query - Given there are 4 dummy objects having each 4 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(first: 2) { - edges { - node { - name - relatedDummies(first: 2) { - edges { - node { - name - } - cursor - } - totalCount - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - totalCount - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.dummies.pageInfo.hasNextPage" should be true - And the JSON node "data.dummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].cursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.hasNextPage" should be true - And the JSON node "data.dummies.edges[1].node.relatedDummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy12" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MA==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "MQ==") { - edges { - node { - name - relatedDummies(first: 2, after: "MA==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "Mg==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy24" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "Mg==") { - edges { - node { - name - relatedDummies(first: 3, after: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #4" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy44" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].cursor" should be equal to "Mw==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "Mw==") { - edges { - node { - name - relatedDummies(first: 1, after: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - - @createSchema - Scenario: Paginate backwards through collections through a GraphQL query - Given there are 4 dummy objects having each 4 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(last: 2) { - edges { - node { - name - relatedDummies(last: 2) { - edges { - node { - name - } - cursor - } - totalCount - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - totalCount - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.dummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #4" - And the JSON node "data.dummies.edges[1].cursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.dummies.edges[1].node.relatedDummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy34" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "Mg==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "Mw==") { - edges { - node { - name - relatedDummies(last: 2, before: "Mg==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy13" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MA==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "MQ==") { - edges { - node { - name - relatedDummies(last: 3, before: "Mg==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.dummies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #1" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy21" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "MA==") { - edges { - node { - name - relatedDummies(last: 1, before: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - - @!mongodb - @createSchema - Scenario: Paginate through a collection through a GraphQL query with a partial pagination - Given there are 4 of these so many objects - When I send the following GraphQL request: - """ - { - soManies(first: 2) { - edges { - node { - content - } - cursor - } - totalCount - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.soManies.totalCount" should be equal to 0 - And the JSON node "data.soManies.edges[1].node.content" should be equal to "Many #2" - And the JSON node "data.soManies.edges[1].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "MQ==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.soManies.edges[0].node.content" should be equal to "Many #3" - And the JSON node "data.soManies.edges[0].cursor" should be equal to "Mg==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "Mg==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.edges" should have 1 element - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.soManies.edges[0].node.content" should be equal to "Many #4" - And the JSON node "data.soManies.edges[0].cursor" should be equal to "Mw==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "Mw==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.edges" should have 0 element - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "NA==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - - @createSchema - Scenario: Retrieve a collection with pagination disabled - Given there are 4 foo objects with fake names - When I send the following GraphQL request: - """ - { - foos { - id - name - bar - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.foos[3].id" should be equal to "/foos/4" - And the JSON node "data.foos[3].name" should be equal to "Separativeness" - And the JSON node "data.foos[3].bar" should be equal to "Sit" - - Scenario: Custom collection query - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionDummyCustomQueries { - edges { - node { - message - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionDummyCustomQueries": { - "edges": [ - { - "node": {"message": "Success!"} - }, - { - "node": {"message": "Success!"} - } - ] - } - } - } - """ - - @createSchema - Scenario: Custom collection query with read and serialize set to false - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionNoReadAndSerializeDummyCustomQueries { - edges { - node { - message - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionNoReadAndSerializeDummyCustomQueries": { - "edges": [] - } - } - } - """ - - @createSchema - Scenario: Custom collection query with custom arguments - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionCustomArgumentsDummyCustomQueries(customArgumentString: "A string") { - edges { - node { - message - customArgs - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionCustomArgumentsDummyCustomQueries": { - "edges": [ - { - "node": {"message": "Success!", "customArgs": {"customArgumentString": "A string"}} - }, - { - "node": {"message": "Success!", "customArgs": {"customArgumentString": "A string"}} - } - ] - } - } - } - """ - - @!mongodb - @createSchema - Scenario: Retrieve an item with composite primitive identifiers through a GraphQL query - Given there are composite primitive identifiers objects - When I send the following GraphQL request: - """ - { - compositePrimitiveItem(id: "/composite_primitive_items/name=Bar;year=2017") { - description - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.compositePrimitiveItem.description" should be equal to "This is bar." - - @!mongodb - @createSchema - Scenario: Retrieve an item with composite identifiers through a GraphQL query - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - { - compositeRelation(id: "/composite_relations/compositeItem=1;compositeLabel=1") { - value - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.compositeRelation.value" should be equal to "somefoobardummy" - - @createSchema - Scenario: Retrieve a collection using name converter - Given there are 4 dummy objects - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name_converted - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[1].node.name_converted" should be equal to "Converted 2" - - @createSchema - Scenario: Retrieve a collection with different serialization groups for item_query and collection_query - Given there are 3 dummy with different GraphQL serialization groups objects - When I send the following GraphQL request: - """ - { - dummyDifferentGraphQlSerializationGroups { - edges { - node { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.title" should not exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.title" should not exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.title" should not exist - - @createSchema - Scenario: Retrieve a paginated collection using page-based pagination - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1) { - collection { - id - name - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 3 elements - And the JSON node "data.fooDummies.collection[0].id" should exist - And the JSON node "data.fooDummies.collection[0].name" should exist - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[2].id" should exist - And the JSON node "data.fooDummies.collection[2].name" should exist - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - When I send the following GraphQL request: - """ - { - fooDummies(page: 3) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 0 elements - - @createSchema - Scenario: Retrieve a paginated collection using page-based pagination and client-defined limit - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[0].id" should exist - And the JSON node "data.fooDummies.collection[0].name" should exist - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - When I send the following GraphQL request: - """ - { - fooDummies(page: 2, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - When I send the following GraphQL request: - """ - { - fooDummies(page: 3, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 1 element - - @createSchema - Scenario: Retrieve paginated collections using mixed pagination - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 3 elements - And the JSON node "data.fooDummies.collection[2].id" should exist - And the JSON node "data.fooDummies.collection[2].name" should exist - And the JSON node "data.fooDummies.collection[2].soManies" should exist - And the JSON node "data.fooDummies.collection[2].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[2].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[2].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[1].soManies" should exist - And the JSON node "data.fooDummies.collection[1].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[1].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[1].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be false - - @createSchema - Scenario: Retrieve paginated collections using only hasNextPage - Given there are 4 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1, itemsPerPage: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[1].soManies" should exist - And the JSON node "data.fooDummies.collection[1].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[1].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[1].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be false diff --git a/features/graphql/docs.feature b/features/graphql/docs.feature deleted file mode 100644 index 7c54a7343f0..00000000000 --- a/features/graphql/docs.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Documentation support - In order to play with GraphQL - As a client software developer - I want to reach the GraphQL documentation - - Scenario: Retrieve the OpenAPI documentation - Given I add "Accept" header equal to "text/html" - And I send a "GET" request to "/graphql" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "text/html; charset=utf-8" diff --git a/features/graphql/filters.feature b/features/graphql/filters.feature deleted file mode 100644 index b5927c6598d..00000000000 --- a/features/graphql/filters.feature +++ /dev/null @@ -1,302 +0,0 @@ -Feature: Collections filtering - In order to retrieve subsets of collections - As an API consumer - I need to be able to set filters - - @createSchema - Scenario: Retrieve a collection filtered using the boolean filter - Given there is 1 dummy object with dummyBoolean true - And there is 1 dummy object with dummyBoolean false - When I send the following GraphQL request: - """ - { - dummies(dummyBoolean: false) { - edges { - node { - id - dummyBoolean - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.dummyBoolean" should be false - - @createSchema - Scenario: Retrieve a collection filtered using the exists filter - Given there are 3 dummy objects - And there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(exists: [{relatedDummy: true}]) { - edges { - node { - id - relatedDummy { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the JSON node "data.dummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummy" should have 1 element - - @createSchema - Scenario: Retrieve a collection filtered using the date filter - Given there are 3 dummy objects with dummyDate - When I send the following GraphQL request: - """ - { - dummies(dummyDate: [{after: "2015-04-02"}]) { - edges { - node { - id - dummyDate - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.dummyDate" should be equal to "2015-04-02" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter - Given there are 10 dummy objects - When I send the following GraphQL request: - """ - { - dummies(name: "#2") { - edges { - node { - id - name - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.id" should be equal to "/dummies/2" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter with an int - Given there are 4 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(name: "Dummy #1") { - totalCount - edges { - node { - name - relatedDummies(age: 31) { - totalCount - edges { - node { - id - name - age - } - } - } - } - } - } - } - """ - Then the JSON node "data.dummies.totalCount" should be equal to 1 - And the JSON node "data.dummies.edges[0].node.relatedDummies.totalCount" should be equal to 1 - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[0].node.age" should be equal to "31" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter and a name converter - Given there are 10 dummy objects - When I send the following GraphQL request: - """ - { - dummies(name_converted: "Converted 2") { - edges { - node { - id - name - name_converted - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.id" should be equal to "/dummies/2" - And the JSON node "data.dummies.edges[0].node.name_converted" should be equal to "Converted 2" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter and a name converter - Given there are 20 convertedOwner objects with convertedRelated - When I send the following GraphQL request: - """ - { - convertedOwners(name_converted__name_converted: "Converted 2") { - edges { - node { - id - name_converted { - name_converted - } - } - } - } - } - """ - Then the JSON node "data.convertedOwners.edges" should have 2 element - And the JSON node "data.convertedOwners.edges[0].node.id" should be equal to "/converted_owners/2" - And the JSON node "data.convertedOwners.edges[0].node.name_converted.name_converted" should be equal to "Converted 2" - And the JSON node "data.convertedOwners.edges[1].node.id" should be equal to "/converted_owners/20" - And the JSON node "data.convertedOwners.edges[1].node.name_converted.name_converted" should be equal to "Converted 20" - - @createSchema - Scenario: Retrieve a nested collection filtered using the search filter - Given there are 3 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - id - relatedDummies(name: "RelatedDummy13") { - edges { - node { - id - name - } - } - } - } - } - } - } - """ - Then the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 0 elements - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges" should have 0 elements - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges" should have 1 element - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy13" - - @createSchema - Scenario: Use a filter of a nested collection - Given there is a DummyCar entity with related colors - When I send the following GraphQL request: - """ - { - dummyCar(id: "/dummy_cars/1") { - id - colors(prop: "blue") { - edges { - node { - id - prop - } - } - } - } - } - """ - Then the JSON node "data.dummyCar.colors.edges" should have 1 element - And the JSON node "data.dummyCar.colors.edges[0].node.prop" should be equal to "blue" - - @createSchema - Scenario: Retrieve a collection filtered using the related search filter - Given there are 1 dummy objects having each 2 relatedDummies - And there are 1 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(relatedDummies__name: "RelatedDummy31") { - edges { - node { - id - } - } - } - } - """ - And the response status code should be 200 - And the JSON node "data.dummies.edges" should have 1 element - - @createSchema - Scenario: Retrieve a collection ordered using nested properties - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(order: [{relatedDummy__name: "DESC"}]) { - edges { - node { - name - relatedDummy { - id - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #1" - - @createSchema - Scenario: Retrieve a collection ordered correctly given the order of the argument - Given there are dummies with similar properties - When I send the following GraphQL request: - """ - { - dummies(order: [{description: "ASC"}, {name: "ASC"}]) { - edges { - node { - id - name - description - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "baz" - And the JSON node "data.dummies.edges[0].node.description" should be equal to "bar" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "foo" - And the JSON node "data.dummies.edges[1].node.description" should be equal to "bar" - - @createSchema - Scenario: Retrieve a collection filtered using the related search filter with two values and exact strategy - Given there are 3 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(relatedDummy__name_list: ["RelatedDummy #1", "RelatedDummy #2"]) { - edges { - node { - id - name - relatedDummy { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 2 element - And the JSON node "data.dummies.edges[0].node.relatedDummy.name" should be equal to "RelatedDummy #1" - And the JSON node "data.dummies.edges[1].node.relatedDummy.name" should be equal to "RelatedDummy #2" diff --git a/features/graphql/input_output.feature b/features/graphql/input_output.feature deleted file mode 100644 index aac22be3f3c..00000000000 --- a/features/graphql/input_output.feature +++ /dev/null @@ -1,202 +0,0 @@ -Feature: GraphQL DTO input and output - In order to use the GraphQL API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - - @createSchema - Scenario: Retrieve an Output with GraphQL - Given there is a RelatedDummy with 0 friends - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_dto_input_outputs" with body: - """ - { - "foo": "test", - "bar": 1, - "relatedDummies": ["/related_dummies/1"] - } - """ - Then the response status code should be 201 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "test", - "relatedDummies": [ - { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "name": "RelatedDummy with friends", - "dummyDate": null, - "thirdLevel": null, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": { - "@type": "EmbeddableDummy", - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "id": 1, - "symfony": "symfony", - "age": null - } - ] - } - """ - When I send the following GraphQL request: - """ - { - dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { - _id, id, baz, - relatedDummies { - edges { - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoInputOutput": { - "_id": 1, - "id": "/dummy_dto_input_outputs/1", - "baz": 1, - "relatedDummies": { - "edges": [ - { - "node": { - "name": "RelatedDummy with friends" - } - } - ] - } - } - } - } - """ - - Scenario: Create an item with custom input and output - When I send the following GraphQL request: - """ - mutation { - createDummyDtoInputOutput(input: {foo: "A foo", bar: 4, clientMutationId: "myId"}) { - dummyDtoInputOutput { - baz, - bat - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "createDummyDtoInputOutput": { - "dummyDtoInputOutput": { - "baz": 4, - "bat": "A foo" - }, - "clientMutationId": "myId" - } - } - } - """ - - Scenario: Create an item using custom inputClass & disabled outputClass - Given there are 2 dummyDtoNoOutput objects - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { - dummyDtoNoOutput { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be a superset of: - """ - { - "errors": [ - { - "message": "Cannot query field \"id\" on type \"DummyDtoNoOutput\".", - "locations": [ - { - "line": 4, - "column": 7 - } - ] - } - ] - } - """ - - Scenario: Cannot create an item with input fields using disabled inputClass - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "lorem" is not defined by type "?createDummyDtoNoInputInput"?\.$/' - And the JSON node "errors[1].message" should match '/^Field "ipsum" is not defined by type "?createDummyDtoNoInputInput"?\.$/' - - Scenario: Use messenger with GraphQL and an input where the handler gives a synchronous result - When I send the following GraphQL request: - """ - mutation { - createMessengerWithInput(input: {var: "test"}) { - messengerWithInput { id, name } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "createMessengerWithInput": { - "messengerWithInput": { - "id": "/messenger_with_inputs/1", - "name": "test" - } - } - } - } - """ diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature deleted file mode 100644 index 356fc67c577..00000000000 --- a/features/graphql/introspection.feature +++ /dev/null @@ -1,621 +0,0 @@ -Feature: GraphQL introspection support - - @createSchema - Scenario: Execute an empty GraphQL query - When I send a "GET" request to "/graphql" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 400 - And the JSON node "errors[0].message" should be equal to "GraphQL query is not valid." - - Scenario: Introspect the GraphQL schema - When I send the query to introspect the schema - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__schema.types" should exist - And the JSON node "data.__schema.queryType.name" should be equal to "Query" - And the JSON node "data.__schema.mutationType.name" should be equal to "Mutation" - - Scenario: Introspect types - When I send the following GraphQL request: - """ - { - type1: __type(name: "DummyProduct") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type2: __type(name: "DummyAggregateOfferCursorConnection") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type3: __type(name: "DummyAggregateOfferEdge") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.type1.description" should be equal to "Dummy Product." - And the JSON node "data.type1.fields" should contain: - """ - { - "name":"offers", - "type":{ - "name":"DummyAggregateOfferCursorConnection", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.type2.fields" should contain: - """ - { - "name":"edges", - "type":{ - "name":null, - "kind":"LIST", - "ofType":{ - "name":"DummyAggregateOfferEdge", - "kind":"OBJECT" - } - } - } - """ - And the JSON node "data.type3.fields" should contain: - """ - { - "name":"node", - "type":{ - "name":"DummyAggregateOffer", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.type3.fields" should contain: - """ - { - "name":"cursor", - "type":{ - "name":null, - "kind":"NON_NULL", - "ofType":{ - "name":"String", - "kind":"SCALAR" - } - } - } - """ - - Scenario: Introspect types with different serialization groups for item_query and collection_query - When I send the following GraphQL request: - """ - { - type1: __type(name: "DummyDifferentGraphQlSerializationGroupCollection") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type2: __type(name: "DummyDifferentGraphQlSerializationGroupItem") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.type1.description" should be equal to "Dummy with different serialization groups for item_query and collection_query." - And the JSON node "data.type1.fields[3].name" should not exist - And the JSON node "data.type2.fields[3].name" should be equal to "title" - - Scenario: Introspect deprecated queries - When I send the following GraphQL request: - """ - { - __type (name: "Query") { - name - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "deprecatedResources" is deprecated for the reason "This resource is deprecated" - - Scenario: Introspect deprecated mutations - When I send the following GraphQL request: - """ - { - __type (name: "Mutation") { - name - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deleteDeprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "updateDeprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "createDeprecatedResource" is deprecated for the reason "This resource is deprecated" - - Scenario: Introspect a deprecated field - When I send the following GraphQL request: - """ - { - __type(name: "DeprecatedResource") { - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deprecatedField" is deprecated for the reason "This field is deprecated" - - Scenario: Retrieve the Relay's node interface - When I send the following GraphQL request: - """ - { - __type(name: "Node") { - name - kind - fields { - name - type { - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "__type": { - "name": "Node", - "kind": "INTERFACE", - "fields": [ - { - "name": "id", - "type": { - "kind": "NON_NULL", - "ofType": { - "name": "ID", - "kind": "SCALAR" - } - } - } - ] - } - } - } - """ - - Scenario: Retrieve the Relay's node field - When I send the following GraphQL request: - """ - { - __schema { - queryType { - fields { - name - type { - name - kind - } - args { - name - type { - kind - ofType { - name - kind - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__schema.queryType.fields[0].name" should be equal to "node" - And the JSON node "data.__schema.queryType.fields[0].type.name" should be equal to "Node" - And the JSON node "data.__schema.queryType.fields[0].type.kind" should be equal to "INTERFACE" - And the JSON node "data.__schema.queryType.fields[0].args[0].name" should be equal to "id" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.kind" should be equal to "NON_NULL" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.ofType.name" should be equal to "ID" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.ofType.kind" should be equal to "SCALAR" - - Scenario: Introspect an Iterable type field - When I send the following GraphQL request: - """ - { - __type(name: "Dummy") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__type.fields" should contain: - """ - { - "name":"jsonData", - "type":{ - "name":"Iterable", - "kind":"SCALAR", - "ofType":null - } - } - """ - - Scenario: Retrieve entity - using serialization groups - fields - When I send the following GraphQL request: - """ - { - typeQuery: __type(name: "DummyGroup") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreateInput: __type(name: "createDummyGroupInput") { - description, - inputFields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayload: __type(name: "createDummyGroupPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayloadData: __type(name: "createDummyGroupPayloadData") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeQuery.fields" should have 2 elements - And the JSON node "data.typeQuery.fields[0].name" should be equal to "id" - And the JSON node "data.typeQuery.fields[1].name" should be equal to "foo" - And the JSON node "data.typeCreateInput.inputFields" should have 3 elements - And the JSON node "data.typeCreateInput.inputFields[0].name" should be equal to "bar" - And the JSON node "data.typeCreateInput.inputFields[1].name" should be equal to "baz" - And the JSON node "data.typeCreateInput.inputFields[2].name" should be equal to "clientMutationId" - And the JSON node "data.typeCreatePayload.fields" should have 2 elements - And the JSON node "data.typeCreatePayload.fields[0].name" should be equal to "dummyGroup" - And the JSON node "data.typeCreatePayload.fields[0].type.name" should be equal to "createDummyGroupPayloadData" - And the JSON node "data.typeCreatePayload.fields[1].name" should be equal to "clientMutationId" - And the JSON node "data.typeCreatePayloadData.fields" should have 2 elements - And the JSON node "data.typeCreatePayloadData.fields[0].name" should be equal to "id" - And the JSON node "data.typeCreatePayloadData.fields[1].name" should be equal to "bar" - - Scenario: Retrieve nested mutation payload data fields - When I send the following GraphQL request: - """ - { - typeCreatePayload: __type(name: "createDummyPropertyPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayloadData: __type(name: "createDummyPropertyPayloadData") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreateNestedPayload: __type(name: "createDummyGroupNestedPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeCreatePayload.fields" should be equal to: - """ - [ - { - "name":"dummyProperty", - "type":{ - "name":"createDummyPropertyPayloadData", - "kind":"OBJECT", - "ofType":null - } - }, - { - "name":"clientMutationId", - "type":{ - "name":"String", - "kind":"SCALAR", - "ofType":null - } - } - ] - """ - And the JSON node "data.typeCreatePayloadData.fields" should contain: - """ - { - "name":"group", - "type":{ - "name":"createDummyGroupNestedPayload", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.typeCreateNestedPayload.fields" should contain: - """ - { - "name":"id", - "type":{ - "name":null, - "kind":"NON_NULL", - "ofType":{ - "name":"ID", - "kind":"SCALAR" - } - } - } - """ - - Scenario: Retrieve a type name through a GraphQL query - Given there are 4 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummy: dummy(id: "/dummies/3") { - name - relatedDummy { - id - name - __typename - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.name" should be equal to "Dummy #3" - And the JSON node "data.dummy.relatedDummy.name" should be equal to "RelatedDummy #3" - And the JSON node "data.dummy.relatedDummy.__typename" should be equal to "RelatedDummy" - - Scenario: Introspect a type available only through relations - When I send the following GraphQL request: - """ - { - typeNotAvailable: __type(name: "VoDummyInspectionCursorConnection") { - description - } - typeOwner: __type(name: "VoDummyCar") { - description, - fields { - name - type { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeNotAvailable" should be null - And the JSON node "data.typeOwner.fields[1].type.name" should be equal to "VoDummyInspectionCursorConnection" - - Scenario: Introspect an enum - When I send the following GraphQL request: - """ - { - person: __type(name: "Person") { - name - fields { - name - type { - name - description - enumValues { - name - description - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.person.fields[1].type.name" should be equal to "GenderTypeEnum" - #And the JSON node "data.person.fields[1].type.description" should be equal to "An enumeration of genders." - And the JSON node "data.person.fields[1].type.enumValues[0].name" should be equal to "MALE" - #And the JSON node "data.person.fields[1].type.enumValues[0].description" should be equal to "The male gender." - And the JSON node "data.person.fields[1].type.enumValues[1].name" should be equal to "FEMALE" - And the JSON node "data.person.fields[1].type.enumValues[1].description" should be equal to "The female gender." - - Scenario: Introspect an enum resource - When I send the following GraphQL request: - """ - { - videoGame: __type(name: "VideoGame") { - name - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.videoGame.fields[3].type.ofType.name" should be equal to "GamePlayMode" diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature deleted file mode 100644 index 7df064279c1..00000000000 --- a/features/graphql/mutation.feature +++ /dev/null @@ -1,1071 +0,0 @@ -Feature: GraphQL mutation support - - @createSchema - Scenario: Introspect types - When I send the following GraphQL request: - """ - { - __type(name: "Mutation") { - fields { - name - description - type { - name - kind - } - args { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "__type" - ], - "properties": { - "__type": { - "type": "object", - "required": [ - "fields" - ], - "properties": { - "fields": { - "type": "array", - "minItems": 1, - "items": { - "oneOf": [ - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+$" - }, - "description": { - "pattern": "^Creates a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+$" - }, - "description": { - "pattern": "^Updates a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+$" - }, - "description": { - "pattern": "^Deletes a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+$" - }, - "description": { - "pattern": "^(?!Create|Update|Delete)[A-z0-9]+s a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - } - ] - } - } - } - } - } - } - } - } - """ - - Scenario: Create an item - When I send the following GraphQL request: - """ - mutation { - createFoo(input: {name: "A new one", bar: "new", clientMutationId: "myId"}) { - foo { - id - _id - __typename - name - bar - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createFoo.foo.id" should be equal to "/foos/1" - And the JSON node "data.createFoo.foo._id" should be equal to 1 - And the JSON node "data.createFoo.foo.__typename" should be equal to "Foo" - And the JSON node "data.createFoo.foo.name" should be equal to "A new one" - And the JSON node "data.createFoo.foo.bar" should be equal to "new" - And the JSON node "data.createFoo.clientMutationId" should be equal to "myId" - - Scenario: Create an item without a clientMutationId - When I send the following GraphQL request: - """ - mutation { - createFoo(input: {name: "Created without mutation id", bar: "works"}) { - foo { - id - name - bar - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createFoo.foo.id" should be equal to "/foos/2" - And the JSON node "data.createFoo.foo.name" should be equal to "Created without mutation id" - And the JSON node "data.createFoo.foo.bar" should be equal to "works" - - Scenario: Create an item with a relation to an existing resource - Given there are 1 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "A dummy", foo: [], relatedDummy: "/related_dummies/1", name_converted: "Converted" clientMutationId: "myId"}) { - dummy { - id - name - foo - relatedDummy { - name - __typename - } - name_converted - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummy.dummy.id" should be equal to "/dummies/2" - And the JSON node "data.createDummy.dummy.name" should be equal to "A dummy" - And the JSON node "data.createDummy.dummy.foo" should have 0 elements - And the JSON node "data.createDummy.dummy.relatedDummy.name" should be equal to "RelatedDummy #1" - And the JSON node "data.createDummy.dummy.relatedDummy.__typename" should be equal to "RelatedDummy" - And the JSON node "data.createDummy.dummy.name_converted" should be equal to "Converted" - And the JSON node "data.createDummy.clientMutationId" should be equal to "myId" - - Scenario: Create an item with an iterable field - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "A dummy", foo: [], jsonData: {bar:{baz:3,qux:[7.6,false,null]}}, arrayData: ["bar", "baz"], clientMutationId: "myId"}) { - dummy { - id - name - foo - jsonData - arrayData - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummy.dummy.id" should be equal to "/dummies/3" - And the JSON node "data.createDummy.dummy.name" should be equal to "A dummy" - And the JSON node "data.createDummy.dummy.foo" should have 0 elements - And the JSON node "data.createDummy.dummy.jsonData.bar.baz" should be equal to the number 3 - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[0]" should be equal to the number 7.6 - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[1]" should be false - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[2]" should be null - And the JSON node "data.createDummy.dummy.arrayData[1]" should be equal to baz - And the JSON node "data.createDummy.clientMutationId" should be equal to "myId" - - Scenario: Create an item with an enum - When I send the following GraphQL request: - """ - mutation { - createPerson(input: {name: "Mob", genderType: FEMALE}) { - person { - id - name - genderType - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createPerson.person.id" should be equal to "/people/1" - And the JSON node "data.createPerson.person.name" should be equal to "Mob" - And the JSON node "data.createPerson.person.genderType" should be equal to "FEMALE" - - @!mongodb - Scenario: Create an item with an enum collection - When I send the following GraphQL request: - """ - mutation { - createPerson(input: {name: "Harry", academicGrades: [BACHELOR, MASTER]}) { - person { - id - name - genderType - academicGrades - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createPerson.person.id" should be equal to "/people/2" - And the JSON node "data.createPerson.person.name" should be equal to "Harry" - And the JSON node "data.createPerson.person.genderType" should be equal to "MALE" - And the JSON node "data.createPerson.person.academicGrades" should have 2 elements - And the JSON node "data.createPerson.person.academicGrades[0]" should be equal to "BACHELOR" - And the JSON node "data.createPerson.person.academicGrades[1]" should be equal to "MASTER" - - Scenario: Create an item with an enum as a resource - When I send the following GraphQL request: - """ - { - gamePlayModes { - id - name - } - gamePlayMode(id: "/game_play_modes/SINGLE_PLAYER") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.gamePlayModes" should have 3 elements - And the JSON node "data.gamePlayModes[2].id" should be equal to "/game_play_modes/SINGLE_PLAYER" - And the JSON node "data.gamePlayModes[2].name" should be equal to "SINGLE_PLAYER" - And the JSON node "data.gamePlayMode.name" should be equal to "SINGLE_PLAYER" - When I send the following GraphQL request: - """ - mutation { - createVideoGame(input: {name: "Baten Kaitos", playMode: "/game_play_modes/SINGLE_PLAYER"}) { - videoGame { - id - name - playMode { - id - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createVideoGame.videoGame.id" should be equal to "/video_games/1" - And the JSON node "data.createVideoGame.videoGame.name" should be equal to "Baten Kaitos" - And the JSON node "data.createVideoGame.videoGame.playMode.id" should be equal to "/game_play_modes/SINGLE_PLAYER" - And the JSON node "data.createVideoGame.videoGame.playMode.name" should be equal to "SINGLE_PLAYER" - - Scenario: Delete an item through a mutation - When I send the following GraphQL request: - """ - mutation { - deleteFoo(input: {id: "/foos/1", clientMutationId: "anotherId"}) { - foo { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.deleteFoo.foo.id" should be equal to "/foos/1" - And the JSON node "data.deleteFoo.clientMutationId" should be equal to "anotherId" - - Scenario: Trigger an error trying to delete item of different resource - When I send the following GraphQL request: - """ - mutation { - deleteFoo(input: {id: "/dummies/1", clientMutationId: "myId"}) { - foo { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should be equal to 'Item "/dummies/1" did not match expected type "Foo".' - - @!mongodb - Scenario: Delete an item with composite identifiers through a mutation - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - mutation { - deleteCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=1", clientMutationId: "myId"}) { - compositeRelation { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.deleteCompositeRelation.compositeRelation.id" should be equal to "/composite_relations/compositeItem=1;compositeLabel=1" - And the JSON node "data.deleteCompositeRelation.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Modify an item through a mutation - Given there are 1 dummy objects having each 2 relatedDummies - When I send the following GraphQL request: - """ - mutation { - updateDummy(input: {id: "/dummies/1", description: "Modified description.", dummyDate: "2018-06-05T00:00:00+00:00", clientMutationId: "myId"}) { - dummy { - id - name - description - dummyDate - relatedDummies { - edges { - node { - name - } - } - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.id" should be equal to "/dummies/1" - And the JSON node "data.updateDummy.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.updateDummy.dummy.description" should be equal to "Modified description." - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2018-06-05" - And the JSON node "data.updateDummy.dummy.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy11" - And the JSON node "data.updateDummy.clientMutationId" should be equal to "myId" - - @createSchema - @!mongodb - Scenario: Modify an item with embedded object through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateFooDummy.fooDummy.name" should be equal to "modifiedName" - And the JSON node "data.updateFooDummy.fooDummy.embeddedFoo.dummyName" should be equal to "Embedded name" - And the JSON node "data.updateFooDummy.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Try to modify a non writable property through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", nonWritableProp: "written", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "nonWritableProp" is not defined by type "?updateFooDummyInput"?\.$/' - - @createSchema - @!mongodb - Scenario: Try to modify a non writable embedded property through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name", nonWritableProp: "written"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "nonWritableProp" is not defined by type "?FooEmbeddableNestedInput"?\.$/' - - @!mongodb - Scenario: Modify an item with composite identifiers through a mutation - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - mutation { - updateCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=2", value: "Modified value.", clientMutationId: "myId"}) { - compositeRelation { - id - value - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateCompositeRelation.compositeRelation.id" should be equal to "/composite_relations/compositeItem=1;compositeLabel=2" - And the JSON node "data.updateCompositeRelation.compositeRelation.value" should be equal to "Modified value." - And the JSON node "data.updateCompositeRelation.clientMutationId" should be equal to "myId" - - Scenario: Create an item with a custom UUID - When I send the following GraphQL request: - """ - mutation { - createWritableId(input: {_id: "c6b722fe-0331-48c4-a214-f81f9f1ca082", name: "Foo", clientMutationId: "m"}) { - writableId { - id - _id - name - } - clientMutationId - } - } - """ - Then the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createWritableId.writableId.id" should be equal to "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082" - And the JSON node "data.createWritableId.writableId._id" should be equal to "c6b722fe-0331-48c4-a214-f81f9f1ca082" - And the JSON node "data.createWritableId.writableId.name" should be equal to "Foo" - And the JSON node "data.createWritableId.clientMutationId" should be equal to "m" - - @!mongodb - Scenario: Update an item with a custom UUID - When I send the following GraphQL request: - """ - mutation { - updateWritableId(input: {id: "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082", _id: "f8a708b2-310f-416c-9aef-b1b5719dfa47", name: "Foo", clientMutationId: "m"}) { - writableId { - id - _id - name - } - clientMutationId - } - } - """ - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateWritableId.writableId.id" should be equal to "/writable_ids/f8a708b2-310f-416c-9aef-b1b5719dfa47" - And the JSON node "data.updateWritableId.writableId._id" should be equal to "f8a708b2-310f-416c-9aef-b1b5719dfa47" - And the JSON node "data.updateWritableId.writableId.name" should be equal to "Foo" - And the JSON node "data.updateWritableId.clientMutationId" should be equal to "m" - - Scenario: Use serialization groups - Given there are 1 dummy group objects - When I send the following GraphQL request: - """ - mutation { - createDummyGroup(input: {bar: "Bar", baz: "Baz", clientMutationId: "myId"}) { - dummyGroup { - id - bar - __typename - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummyGroup.dummyGroup.id" should be equal to "/dummy_groups/2" - And the JSON node "data.createDummyGroup.dummyGroup.bar" should be equal to "Bar" - And the JSON node "data.createDummyGroup.dummyGroup.__typename" should be equal to "createDummyGroupPayloadData" - And the JSON node "data.createDummyGroup.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Use serialization groups with relations - Given there is 1 dummy object with relatedDummy and its thirdLevel - And there is a RelatedDummy with 2 friends - And there is a dummy object with a fourth level relation - When I send the following GraphQL request: - """ - mutation { - updateRelatedDummy(input: { - id: "/related_dummies/2", - symfony: "laravel", - thirdLevel: { - fourthLevel: "/fourth_levels/1" - } - }) { - relatedDummy { - id - symfony - thirdLevel { - id - fourthLevel { - id - __typename - } - __typename - } - relatedToDummyFriend { - edges { - node { - name - } - } - __typename - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateRelatedDummy.relatedDummy.id" should be equal to "/related_dummies/2" - And the JSON node "data.updateRelatedDummy.relatedDummy.symfony" should be equal to "laravel" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.id" should be equal to "/third_levels/3" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.__typename" should be equal to "updateThirdLevelNestedPayload" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.fourthLevel.id" should be equal to "/fourth_levels/1" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.fourthLevel.__typename" should be equal to "updateFourthLevelNestedPayload" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.__typename" should be equal to "updateRelatedToDummyFriendNestedPayloadCursorConnection" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.edges[0].node.name" should be equal to "Relation-1" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.edges[1].node.name" should be equal to "Relation-2" - - Scenario: Trigger a validation error - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "", foo: [], clientMutationId: "myId"}) { - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to "422" - And the JSON node "errors[0].message" should be equal to "name: This value should not be blank." - And the JSON node "errors[0].extensions.violations" should exist - And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name" - And the JSON node "errors[0].extensions.violations[0].message" should be equal to "This value should not be blank." - - @createSchema - Scenario: Execute a custom mutation - Given there are 1 dummyCustomMutation objects - When I send the following GraphQL request: - """ - mutation { - sumDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumDummyCustomMutation.dummyCustomMutation.result" should be equal to "8" - - @createSchema - Scenario: Execute a not persisted custom mutation (resolver returns null) - Given there are 1 dummyCustomMutation objects - When I send the following GraphQL request: - """ - mutation { - sumNotPersistedDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumNotPersistedDummyCustomMutation.dummyCustomMutation" should be null - - Scenario: Execute a not persisted custom mutation (write set to false) with custom result - When I send the following GraphQL request: - """ - mutation { - sumNoWriteCustomResultDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumNoWriteCustomResultDummyCustomMutation.dummyCustomMutation.result" should be equal to "1234" - - Scenario: Execute a custom mutation with read, deserialize, validate and serialize set to false - When I send the following GraphQL request: - """ - mutation { - sumOnlyPersistDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumOnlyPersistDummyCustomMutation.dummyCustomMutation" should be null - - Scenario: Execute a custom mutation with custom arguments - When I send the following GraphQL request: - """ - mutation { - testCustomArgumentsDummyCustomMutation(input: {operandC: 18, clientMutationId: "myId"}) { - dummyCustomMutation { - result - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.testCustomArgumentsDummyCustomMutation.dummyCustomMutation.result" should be equal to "18" - And the JSON node "data.testCustomArgumentsDummyCustomMutation.clientMutationId" should be equal to "myId" - - Scenario: Uploading a file with a custom mutation - Given I have the following file for a GraphQL request: - | name | file | - | file | test.gif | - And I have the following GraphQL multipart request map: - """ - { - "file": ["variables.file"] - } - """ - When I send the following GraphQL multipart request operations: - """ - { - "query": "mutation($file: Upload!) { uploadMediaObject(input: {file: $file}) { mediaObject { id contentUrl } } }", - "variables": { - "file": null - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.uploadMediaObject.mediaObject.contentUrl" should be equal to "test.gif" - - Scenario: Uploading multiple files with a custom mutation - Given I have the following files for a GraphQL request: - | name | file | - | 0 | test.gif | - | 1 | test.gif | - | 2 | test.gif | - And I have the following GraphQL multipart request map: - """ - { - "0": ["variables.files.0"], - "1": ["variables.files.1"], - "2": ["variables.files.2"] - } - """ - When I send the following GraphQL multipart request operations: - """ - { - "query": "mutation($files: [Upload!]!) { uploadMultipleMediaObject(input: {files: $files}) { mediaObject { id contentUrl } } }", - "variables": { - "files": [ - null, - null, - null - ] - } - } - """ - Then the response status code should be 200 - And the JSON node "data.uploadMultipleMediaObject.mediaObject.contentUrl" should be equal to "test.gif" - - @!mongodb - Scenario: Delete an invalid item through a mutation - When I send the following GraphQL request: - """ - mutation { - deleteActivityLog(input: {id: "/activity_logs/1"}) { - activityLog { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.deleteActivityLog.activityLog" should exist - - @!mongodb - Scenario: Mutation should run before validation - When I send the following GraphQL request: - """ - mutation { - createActivityLog(input: {name: ""}) { - activityLog { - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createActivityLog.activityLog.name" should be equal to "hi" diff --git a/features/graphql/query.feature b/features/graphql/query.feature deleted file mode 100644 index 732540a65cb..00000000000 --- a/features/graphql/query.feature +++ /dev/null @@ -1,696 +0,0 @@ -Feature: GraphQL query support - - @createSchema - Scenario: Execute a basic GraphQL query - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - id - name - name_converted - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.id" should be equal to "/dummies/1" - And the JSON node "data.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.dummy.name_converted" should be equal to "Converted 1" - - @createSchema - Scenario: Retrieve an item with different relations to the same resource - Given there are 2 multiRelationsDummy objects having each 1 manyToOneRelation, 2 manyToManyRelations, 3 oneToManyRelations and 4 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 3 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - - @createSchema - Scenario: Retrieve embedded collections - Given there are 2 multiRelationsDummy objects having each 1 manyToOneRelation, 2 manyToManyRelations, 3 oneToManyRelations and 4 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - nestedCollection { - name - } - nestedPaginatedCollection { - edges{ - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation.name" should be equal to "RelatedManyToOneResolveDummy #2" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 3 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.nestedCollection[0].name" should be equal to "NestedDummy1" - And the JSON node "data.multiRelationsDummy.nestedCollection[1].name" should be equal to "NestedDummy2" - And the JSON node "data.multiRelationsDummy.nestedCollection[2].name" should be equal to "NestedDummy3" - And the JSON node "data.multiRelationsDummy.nestedCollection[3].name" should be equal to "NestedDummy4" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges" should have 4 element - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[0].node.name" should be equal to "NestedPaginatedDummy1" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[1].node.name" should be equal to "NestedPaginatedDummy2" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[2].node.name" should be equal to "NestedPaginatedDummy3" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[3].node.name" should be equal to "NestedPaginatedDummy4" - - @createSchema - Scenario: Retrieve an item with different relations (all unset) - Given there are 2 multiRelationsDummy objects having each 0 manyToOneRelation, 0 manyToManyRelations, 0 oneToManyRelations and 0 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - nestedCollection { - name - } - nestedPaginatedCollection { - edges{ - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation" should be null - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation" should be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 0 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 0 element - And the JSON node "data.multiRelationsDummy.nestedCollection" should have 0 element - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges" should have 0 element - - @createSchema @!mongodb - Scenario: Retrieve an item with child relation to the same resource - Given there are tree dummies - When I send the following GraphQL request: - """ - { - treeDummies { - edges { - node { - id - children { - totalCount - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.treeDummies.edges[0].node.id" should be equal to "/tree_dummies/1" - And the JSON node "data.treeDummies.edges[0].node.children.totalCount" should be equal to "1" - And the JSON node "data.treeDummies.edges[1].node.id" should be equal to "/tree_dummies/2" - And the JSON node "data.treeDummies.edges[1].node.children.totalCount" should be equal to "0" - - @createSchema - Scenario: Retrieve a Relay Node - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - node(id: "/dummies/1") { - id - ... on Dummy { - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.node.id" should be equal to "/dummies/1" - And the JSON node "data.node.name" should be equal to "Dummy #1" - - @createSchema - Scenario: Retrieve an item with an iterable field - Given there are 2 dummy objects with relatedDummy - Given there are 2 dummy objects with JSON and array data - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/3") { - id - name - jsonData - arrayData - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.id" should be equal to "/dummies/3" - And the JSON node "data.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.dummy.jsonData.foo" should have 2 elements - And the JSON node "data.dummy.jsonData.bar" should be equal to 5 - And the JSON node "data.dummy.arrayData[2]" should be equal to baz - - @createSchema - Scenario: Retrieve an item with an iterable null field - Given there are 2 dummy with null JSON objects - When I send the following GraphQL request: - """ - { - withJsonDummy(id: "/with_json_dummies/2") { - id - json - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.withJsonDummy.id" should be equal to "/with_json_dummies/2" - And the JSON node "data.withJsonDummy.json" should be null - - @createSchema - Scenario: Retrieve an item through a GraphQL query with variables - Given there are 2 dummy objects with relatedDummy - When I have the following GraphQL request: - """ - query DummyWithId($itemId: ID = "/dummies/1") { - dummyItem: dummy(id: $itemId) { - id - name - relatedDummy { - id - name - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/2" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyItem.id" should be equal to "/dummies/2" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #2" - And the JSON node "data.dummyItem.relatedDummy.id" should be equal to "/related_dummies/2" - And the JSON node "data.dummyItem.relatedDummy.name" should be equal to "RelatedDummy #2" - - Scenario: Run a specific operation through a GraphQL query - When I have the following GraphQL request: - """ - query DummyWithId1 { - dummyItem: dummy(id: "/dummies/1") { - name - } - } - query DummyWithId2 { - dummyItem: dummy(id: "/dummies/2") { - id - name - } - } - """ - And I send the GraphQL request with operationName "DummyWithId2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyItem.id" should be equal to "/dummies/2" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #2" - And I send the GraphQL request with operationName "DummyWithId1" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #1" - - Scenario: Use serialization groups - Given there are 1 dummy group objects - When I send the following GraphQL request: - """ - { - dummyGroup(id: "/dummy_groups/1") { - foo - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyGroup.foo" should be equal to "Foo #1" - - Scenario: Query a serialized name - Given there is a DummyCar entity with related colors - When I send the following GraphQL request: - """ - { - dummyCar(id: "/dummy_cars/1") { - carBrand - } - } - """ - Then the JSON node "data.dummyCar.carBrand" should be equal to "DummyBrand" - - Scenario: Fetch only the internal id - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - _id - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy._id" should be equal to "1" - - Scenario: Retrieve an nonexistent item through a GraphQL query - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/5") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy" should be null - - Scenario: Retrieve an nonexistent IRI through a GraphQL query - When I send the following GraphQL request: - """ - { - foo(id: "/foo/1") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL debug message should be equal to 'No route matches "/foo/1".' - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": {"type": "string"}, - "extensions": { - "type": "object", - "properties": { - "debugMessage": {"type": "string"}, - "file": {"type": "string"}, - "line": {"type": "integer"}, - "trace": { - "type": "array", - "items": { - "type": "object", - "properties": { - "file": {"type": "string"}, - "line": {"type": "integer"}, - "call": {"type": ["string", "null"]}, - "function": {"type": ["string", "null"]} - }, - "additionalProperties": false - }, - "minItems": 1 - } - } - }, - "locations": {"type": "array"}, - "path": {"type": "array"} - }, - "required": [ - "message", - "extensions", - "locations", - "path" - ] - }, - "minItems": 1, - "maxItems": 1 - } - } - } - """ - - Scenario: Use outputClass instead of resource class through a GraphQL query - Given there are 2 dummyDtoNoInput objects - When I send the following GraphQL request: - """ - { - dummyDtoNoInputs { - edges { - node { - baz - bat - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoNoInputs": { - "edges": [ - { - "node": { - "baz": 0.33, - "bat": "DummyDtoNoInput foo #1" - } - }, - { - "node": { - "baz": 0.67, - "bat": "DummyDtoNoInput foo #2" - } - } - ] - } - } - } - """ - - @createSchema - Scenario: Disable outputClass leads to an empty response through a GraphQL query - Given there are 2 dummyDtoNoOutput objects - When I send the following GraphQL request: - """ - { - dummyDtoNoInputs { - edges { - node { - baz - bat - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoNoInputs": { - "edges": [] - } - } - } - """ - - Scenario: Custom not retrieved item query - When I send the following GraphQL request: - """ - { - testNotRetrievedItemDummyCustomQuery { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testNotRetrievedItemDummyCustomQuery": { - "message": "Success (not retrieved)!" - } - } - } - """ - - Scenario: Custom item query with read and serialize set to false - When I send the following GraphQL request: - """ - { - testNoReadAndSerializeItemDummyCustomQuery(id: "/not_used") { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testNoReadAndSerializeItemDummyCustomQuery": null - } - } - """ - - Scenario: Custom item query - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testItemDummyCustomQuery(id: "/dummy_custom_queries/1") { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testItemDummyCustomQuery": { - "message": "Success!" - } - } - } - """ - - Scenario: Custom item query with custom arguments - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testItemCustomArgumentsDummyCustomQuery( - id: "/dummy_custom_queries/1", - customArgumentBool: true, - customArgumentInt: 3, - customArgumentString: "A string", - customArgumentFloat: 2.6, - customArgumentIntArray: [4], - customArgumentCustomType: "2019-05-24T00:00:00+00:00" - ) { - message - customArgs - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testItemCustomArgumentsDummyCustomQuery": { - "message": "Success!", - "customArgs": { - "id": "/dummy_custom_queries/1", - "customArgumentBool": true, - "customArgumentInt": 3, - "customArgumentString": "A string", - "customArgumentFloat": 2.6, - "customArgumentIntArray": [4], - "customArgumentCustomType": "2019-05-24T00:00:00+00:00" - } - } - } - } - """ - - @createSchema - Scenario: Retrieve an item with different serialization groups for item_query and collection_query - Given there are 1 dummy with different GraphQL serialization groups objects - When I send the following GraphQL request: - """ - { - dummyDifferentGraphQlSerializationGroup(id: "/dummy_different_graph_ql_serialization_groups/1") { - name - title - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyDifferentGraphQlSerializationGroup.name" should be equal to "Name #1" - And the JSON node "data.dummyDifferentGraphQlSerializationGroup.title" should be equal to "Title #1" - - Scenario: Call security after resolver - When I send the following GraphQL request: - """ - { - getSecurityAfterResolver(id: "/security_after_resolvers/1") { - name - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.getSecurityAfterResolver.name" should be equal to "test" - - - Scenario: Call security after resolver with 403 error (ensure /2 does not match securityAfterResolver) - When I send the following GraphQL request: - """" - { - getSecurityAfterResolver(id: "/security_after_resolvers/2") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.getSecurityAfterResolver.name" should not exist diff --git a/features/graphql/schema.feature b/features/graphql/schema.feature deleted file mode 100644 index 86a48cebd13..00000000000 --- a/features/graphql/schema.feature +++ /dev/null @@ -1,113 +0,0 @@ -Feature: GraphQL schema-related features - - @createSchema - Scenario: Export the GraphQL schema in SDL - When I run the command "api:graphql:export" - Then the command output should contain: - """ - ###Dummy Friend.### - type DummyFriend implements Node { - id: ID! - - ###The id### - _id: Int! - - ###The dummy name### - name: String! - } - """ - And the command output should contain: - """ - ###Cursor connection for DummyFriend.### - type DummyFriendCursorConnection { - edges: [DummyFriendEdge] - pageInfo: DummyFriendPageInfo! - totalCount: Int! - } - - ###Edge of DummyFriend.### - type DummyFriendEdge { - node: DummyFriend - cursor: String! - } - - ###Information about the current page.### - type DummyFriendPageInfo { - endCursor: String - startCursor: String - hasNextPage: Boolean! - hasPreviousPage: Boolean! - } - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - updateDummyFriend(input: updateDummyFriendInput!): updateDummyFriendPayload - - ###Deletes a DummyFriend.### - deleteDummyFriend(input: deleteDummyFriendInput!): deleteDummyFriendPayload - - ###Creates a DummyFriend.### - createDummyFriend(input: createDummyFriendInput!): createDummyFriendPayload - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - input updateDummyFriendInput { - id: ID! - - ###The dummy name### - name: String - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - type updateDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Deletes a DummyFriend.### - input deleteDummyFriendInput { - id: ID! - clientMutationId: String - } - - ###Deletes a DummyFriend.### - type deleteDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Creates a DummyFriend.### - input createDummyFriendInput { - ###The dummy name### - name: String! - clientMutationId: String - } - - ###Creates a DummyFriend.### - type createDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - "Updates a OptionalRequiredDummy." - input updateOptionalRequiredDummyInput { - id: ID! - thirdLevel: updateThirdLevelNestedInput - thirdLevelRequired: updateThirdLevelNestedInput! - - "Get relatedToDummyFriend." - relatedToDummyFriend: [updateRelatedToDummyFriendNestedInput] - clientMutationId: String - } - """ diff --git a/features/graphql/subscription.feature b/features/graphql/subscription.feature deleted file mode 100644 index 75863ec04bf..00000000000 --- a/features/graphql/subscription.feature +++ /dev/null @@ -1,224 +0,0 @@ -Feature: GraphQL subscription support - - @createSchema - Scenario: Introspect subscription type - When I send the following GraphQL request: - """ - { - __type(name: "Subscription") { - fields { - name - description - type { - name - kind - } - args { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "__type" - ], - "properties": { - "__type": { - "type": "object", - "required": [ - "fields" - ], - "properties": { - "fields": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Subscribe" - }, - "description": { - "pattern": "^Subscribes to the update event of a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+SubscriptionPayload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+SubscriptionInput$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - } - } - } - } - } - } - } - } - """ - - Scenario: Subscribe to updates - Given there are 2 dummy mercure objects - When I send the following GraphQL request: - """ - subscription { - updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { - dummyMercure { - id - name - relatedDummy { - name - } - } - mercureUrl - clientSubscriptionId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/1" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.name" should be equal to "Dummy Mercure #1" - And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks\?topic=http://example.com/subscriptions/[a-f0-9]+$@" - And the JSON node "data.updateDummyMercureSubscribe.clientSubscriptionId" should be equal to "myId" - - When I send the following GraphQL request: - """ - subscription { - updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { - dummyMercure { - id - } - mercureUrl - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/2" - And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks\?topic=http://example.com/subscriptions/[a-f0-9]+$@" - - Scenario: Receive Mercure updates with different payloads from subscriptions (legacy PUT in non-standard mode) - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mercures/1" with body: - """ - { - "name": "Dummy Mercure #1 updated" - } - """ - Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: - """ - { - "dummyMercure": { - "id": 1, - "name": "Dummy Mercure #1 updated", - "relatedDummy": { - "name": "RelatedDummy #1" - } - } - } - """ - - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mercures/2" with body: - """ - { - "name": "Dummy Mercure #2 updated" - } - """ - Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: - """ - { - "dummyMercure": { - "id": 2 - } - } - """ diff --git a/features/graphql/type.feature b/features/graphql/type.feature deleted file mode 100644 index 03a072785d5..00000000000 --- a/features/graphql/type.feature +++ /dev/null @@ -1,80 +0,0 @@ -Feature: GraphQL type support - - @createSchema - Scenario: Use a custom type for a field - Given there are 2 dummy objects with dummyDate - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - dummyDate - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.dummyDate" should be equal to "2015-04-01" - - Scenario: Use a custom type for an input field - When I send the following GraphQL request: - """ - mutation { - updateDummy(input: {id: "/dummies/1", dummyDate: "2019-05-24T00:00:00+00:00"}) { - dummy { - dummyDate - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2019-05-24" - - Scenario: Use a custom type for a query variable - When I have the following GraphQL request: - """ - mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { - updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { - dummy { - dummyDate - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/1", - "itemDate": "2017-11-14T00:00:00+00:00" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2017-11-14" - - Scenario: Use a custom type for a query variable and use a bad value - When I have the following GraphQL request: - """ - mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { - updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { - dummy { - dummyDate - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/1", - "itemDate": "bad date" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should contain 'Variable "$itemDate" got invalid value "bad date";' - And the JSON node "errors[0].message" should contain 'DateTime cannot represent non date value: "bad date"' diff --git a/src/GraphQl/Test/GraphQlTestTrait.php b/src/GraphQl/Test/GraphQlTestTrait.php new file mode 100644 index 00000000000..630e64b2f53 --- /dev/null +++ b/src/GraphQl/Test/GraphQlTestTrait.php @@ -0,0 +1,141 @@ + + * + * 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\GraphQl\Test; + +use GraphQL\Type\Introspection; +use PHPUnit\Framework\Assert; +use PHPUnit\Framework\ExpectationFailedException; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * Helpers for functional GraphQL tests. + * + * Designed to be mixed into a class that exposes a static `createClient()` returning + * an HTTP client with a `request()` method (e.g. ApiPlatform\Symfony\Bundle\Test\ApiTestCase). + */ +trait GraphQlTestTrait +{ + /** + * @param array $variables + * @param array $headers + */ + protected function executeGraphQl(string $query, array $variables = [], ?string $operationName = null, array $headers = []): ResponseInterface + { + $payload = ['query' => $query]; + + if ($variables) { + $payload['variables'] = $variables; + } + + if (null !== $operationName) { + $payload['operationName'] = $operationName; + } + + $options = ['json' => $payload]; + + if ($headers) { + $options['headers'] = $headers; + } + + return static::createClient()->request('POST', '/graphql', $options); + } + + /** + * @param array $headers + */ + protected function introspectSchema(array $headers = []): ResponseInterface + { + return $this->executeGraphQl(Introspection::getIntrospectionQuery(), [], null, $headers); + } + + /** + * Send a `multipart/form-data` GraphQL request following the + * graphql-multipart-request-spec (https://github.com/jaydenseric/graphql-multipart-request-spec). + * + * @param array $files Map of file marker => absolute file path + * @param array $headers + */ + protected function executeGraphQlMultipart(string $operations, string $map, array $files, array $headers = []): ResponseInterface + { + return static::createClient()->request('POST', '/graphql', [ + 'headers' => ['Content-Type' => 'multipart/form-data'] + $headers, + 'extra' => [ + 'parameters' => ['operations' => $operations, 'map' => $map], + 'files' => $files, + ], + ]); + } + + /** + * @param array{errors?: list} $data + */ + protected function assertGraphQlError(array $data, string $expectedMessage, int $index = 0): void + { + if (!isset($data['errors'][$index])) { + throw new ExpectationFailedException(\sprintf('No GraphQL error at index %d.', $index)); + } + + Assert::assertSame($expectedMessage, $data['errors'][$index]['message'] ?? null); + } + + /** + * Mirrors the Behat `the GraphQL debug message should be equal to` step: + * looks under `errors[$i].extensions.debugMessage` first, falls back to + * `errors[$i].debugMessage` for graphql-php < 15. + * + * @param array{errors?: list>} $data + */ + protected function assertGraphQlDebugMessage(array $data, string $expectedDebugMessage, int $index = 0): void + { + if (!isset($data['errors'][$index])) { + throw new ExpectationFailedException(\sprintf('No GraphQL error at index %d.', $index)); + } + + $error = $data['errors'][$index]; + $debug = $error['extensions']['debugMessage'] ?? $error['debugMessage'] ?? null; + + Assert::assertSame($expectedDebugMessage, $debug); + } + + /** + * Assert that a field returned by a `__type(name: ...) { fields { ... } }` query is + * flagged as deprecated with the given reason. + * + * @param array{data?: array{__type?: array{fields?: list>}}} $data + */ + protected function assertGraphQlFieldDeprecated(array $data, string $fieldName, string $reason): void + { + $fields = $data['data']['__type']['fields'] ?? null; + + if (!\is_array($fields)) { + throw new ExpectationFailedException('Expected response to contain "data.__type.fields".'); + } + + foreach ($fields as $field) { + if (($field['name'] ?? null) !== $fieldName) { + continue; + } + + if (true === ($field['isDeprecated'] ?? null) && $reason === ($field['deprecationReason'] ?? null)) { + Assert::assertTrue(true); + + return; + } + + throw new ExpectationFailedException(\sprintf('Field "%s" is not deprecated with reason "%s".', $fieldName, $reason)); + } + + throw new ExpectationFailedException(\sprintf('Field "%s" not found in "data.__type.fields".', $fieldName)); + } +} diff --git a/tests/Behat/GraphqlContext.php b/tests/Behat/GraphqlContext.php deleted file mode 100644 index ca644baaff9..00000000000 --- a/tests/Behat/GraphqlContext.php +++ /dev/null @@ -1,178 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use Behatch\Context\RestContext; -use Behatch\HttpCall\Request; -use GraphQL\Error\Error; -use GraphQL\Type\Introspection; -use PHPUnit\Framework\ExpectationFailedException; - -/** - * Context for GraphQL. - * - * @author Alan Poulain - */ -final class GraphqlContext implements Context -{ - private ?RestContext $restContext = null; - private ?JsonContext $jsonContext = null; - - private array $graphqlRequest; - - private ?int $graphqlLine = null; // @phpstan-ignore-line - - public function __construct(private readonly Request $request) - { - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** @var InitializedContextEnvironment $environment */ - $environment = $scope->getEnvironment(); - /** @var RestContext $restContext */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - /** @var JsonContext $jsonContext */ - $jsonContext = $environment->getContext(JsonContext::class); - $this->jsonContext = $jsonContext; - } - - /** - * @When I have the following GraphQL request: - */ - public function IHaveTheFollowingGraphqlRequest(PyStringNode $request): void - { - $this->graphqlRequest = ['query' => $request->getRaw()]; - $this->graphqlLine = $request->getLine(); - } - - /** - * @When I send the following GraphQL request: - */ - public function ISendTheFollowingGraphqlRequest(PyStringNode $request): void - { - $this->IHaveTheFollowingGraphqlRequest($request); - $this->sendGraphqlRequest(); - } - - /** - * @When I send the GraphQL request with variables: - */ - public function ISendTheGraphqlRequestWithVariables(PyStringNode $variables): void - { - $this->graphqlRequest['variables'] = $variables->getRaw(); - $this->sendGraphqlRequest(); - } - - /** - * @When I send the GraphQL request with operationName :operationName - */ - public function ISendTheGraphqlRequestWithOperation(string $operationName): void - { - $this->graphqlRequest['operationName'] = $operationName; - $this->sendGraphqlRequest(); - } - - /** - * @Given I have the following file(s) for a GraphQL request: - */ - public function iHaveTheFollowingFilesForAGraphqlRequest(TableNode $table): void - { - $files = []; - - foreach ($table->getHash() as $row) { - if (!isset($row['name'], $row['file'])) { - throw new \InvalidArgumentException('You must provide a "name" and "file" column in your table node.'); - } - - $files[$row['name']] = $this->restContext->getMinkParameter('files_path').\DIRECTORY_SEPARATOR.$row['file']; - } - - $this->graphqlRequest['files'] = $files; - } - - /** - * @Given I have the following GraphQL multipart request map: - */ - public function iHaveTheFollowingGraphqlMultipartRequestMap(PyStringNode $string): void - { - $this->graphqlRequest['map'] = $string->getRaw(); - } - - /** - * @When I send the following GraphQL multipart request operations: - */ - public function iSendTheFollowingGraphqlMultipartRequestOperations(PyStringNode $string): void - { - $params = []; - $params['operations'] = $string->getRaw(); - $params['map'] = $this->graphqlRequest['map']; - - $this->request->setHttpHeader('Content-type', 'multipart/form-data'); - $this->request->send('POST', '/graphql', $params, $this->graphqlRequest['files']); - } - - /** - * @When I send the query to introspect the schema - */ - public function ISendTheQueryToIntrospectTheSchema(): void - { - $this->graphqlRequest = ['query' => Introspection::getIntrospectionQuery()]; - $this->sendGraphqlRequest(); - } - - /** - * @Then the GraphQL field :fieldName is deprecated for the reason :reason - */ - public function theGraphQLFieldIsDeprecatedForTheReason(string $fieldName, string $reason): void - { - foreach (json_decode($this->request->getContent(), true, 512, \JSON_THROW_ON_ERROR)['data']['__type']['fields'] as $field) { - if ($fieldName === $field['name'] && $field['isDeprecated'] && $reason === $field['deprecationReason']) { - return; - } - } - - throw new ExpectationFailedException(\sprintf('The field "%s" is not deprecated.', $fieldName)); - } - - /** - * @Then the GraphQL debug message should be equal to :expectedDebugMessage - */ - public function theGraphQLDebugMessageShouldBeEqualTo(string $expectedDebugMessage): void - { - $jsonNode = 'errors[0].extensions.debugMessage'; - // graphql-php < 15 - if (\defined(Error::class.'::CATEGORY_INTERNAL')) { - $jsonNode = 'errors[0].debugMessage'; - } - - $this->jsonContext->theJsonNodeShouldBeEqualTo($jsonNode, $expectedDebugMessage); - } - - private function sendGraphqlRequest(): void - { - $this->restContext->iSendARequestTo('GET', '/graphql?'.http_build_query($this->graphqlRequest)); - } -} diff --git a/tests/Functional/GraphQl/AuthorizationTest.php b/tests/Functional/GraphQl/AuthorizationTest.php new file mode 100644 index 00000000000..14a238c1ba9 --- /dev/null +++ b/tests/Functional/GraphQl/AuthorizationTest.php @@ -0,0 +1,590 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedLinkedDummy as RelatedLinkedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedSecuredDummy as RelatedSecuredDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as SecuredDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedSecuredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AuthorizationTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private const ADMIN_AUTH = 'Basic YWRtaW46a2l0dGVu'; + private const DUNGLAS_AUTH = 'Basic ZHVuZ2xhczprZXZpbg=='; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + SecuredDummy::class, + RelatedDummy::class, + RelatedSecuredDummy::class, + RelatedLinkedDummy::class, + ]; + } + + public function testAnonymousCannotReadSecuredItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + title + description + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummy']); + } + + public function testAnonymousCannotReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummies']); + } + + public function testAdminCanReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNotNull($response->toArray()['data']['securedDummies']); + } + + public function testUserCannotReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertNull($data['data']['securedDummies']); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + } + + public function testAnonymousCannotCreateSecuredResource(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { + securedDummy { + title + owner + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Only admins can create a secured dummy.', $data['errors'][0]['message']); + $this->assertNull($data['data']['createSecuredDummy']); + } + + public function testAdminCanAccessSecuredRelationsOwnedByAdmin(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'admin'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedDummies { edges { node { id } } } + relatedDummy { id } + relatedSecuredDummies { edges { node { id } } } + relatedSecuredDummy { id } + publicRelatedSecuredDummies { edges { node { id } } } + publicRelatedSecuredDummy { id } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['securedDummy']; + $this->assertCount(1, $data['relatedDummies']['edges']); + $this->assertNotNull($data['relatedDummy']); + $this->assertCount(1, $data['relatedSecuredDummies']['edges']); + $this->assertNotNull($data['relatedSecuredDummy']); + $this->assertCount(1, $data['publicRelatedSecuredDummies']['edges']); + $this->assertNotNull($data['publicRelatedSecuredDummy']); + } + + public function testUserCannotReadSecuredCollectionRelationOnSecuredItemTheyDoNotOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'someone-else'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedDummies { edges { node { id } } } + relatedDummy { id } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $secured = $response->toArray(false)['data']['securedDummy']; + $this->assertNull($secured['relatedDummies']); + $this->assertNull($secured['relatedDummy']); + } + + public function testUserCannotAccessRelatedSecuredDummyDirectly(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + relatedSecuredDummy(id: "/related_secured_dummies/1") { + id + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['relatedSecuredDummy']); + } + + public function testUserCannotListRelatedSecuredDummies(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + relatedSecuredDummies { + edges { node { id } } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['relatedSecuredDummies']); + } + + public function testUserCanAccessSecuredRelationsOnOwnedDummy(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedSecuredDummies { edges { node { id } } } + relatedSecuredDummy { id } + publicRelatedSecuredDummies { edges { node { id } } } + publicRelatedSecuredDummy { id } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['securedDummy']; + $this->assertCount(1, $data['relatedSecuredDummies']['edges']); + $this->assertNotNull($data['relatedSecuredDummy']); + $this->assertCount(1, $data['publicRelatedSecuredDummies']['edges']); + $this->assertNotNull($data['publicRelatedSecuredDummy']); + } + + public function testAdminCanCreateSecuredResource(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { + securedDummy { + id + title + owner + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('someone', $response->toArray()['data']['createSecuredDummy']['securedDummy']['owner']); + } + + public function testAdminCanCreateOwnerOnlyPropertyWhenAdminIsOwner(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "admin", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "it works"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('it works', $response->toArray()['data']['createSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testAdminCannotSetOwnerOnlyPropertyWhenNotOwner(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "should not be set"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['createSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCannotReadItemTheyDoNotOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('admin'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummy']); + } + + public function testUserCanReadItemTheyOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('dunglas', $response->toArray()['data']['securedDummy']['owner']); + } + + public function testAdminCanReadAdminOnlyPropertyOnOtherUsersItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', adminProperty: 'admin secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + adminOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('admin secret', $response->toArray()['data']['securedDummy']['adminOnlyProperty']); + } + + public function testUserCannotReadAdminOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', adminProperty: 'admin secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + adminOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['securedDummy']['adminOnlyProperty']); + } + + public function testUserCanReadOwnerOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'owner secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + ownerOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('owner secret', $response->toArray()['data']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCanUpdateOwnerOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'original'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", ownerOnlyProperty: "updated"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('updated', $response->toArray()['data']['updateSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testAdminCannotReadOwnerOnlyPropertyOnOtherUsersItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'owner secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + ownerOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCannotAssignItemTheyDoNotOwnToThemselves(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('someone'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "kitten"}) { + securedDummy { id title owner } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['updateSecuredDummy']); + } + + public function testUserCanTransferOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "vincent"}) { + securedDummy { id title owner } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('vincent', $response->toArray()['data']['updateSecuredDummy']['securedDummy']['owner']); + } + + private function recreateAuthSchema(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? SecuredDummyDocument::class : SecuredDummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? RelatedSecuredDummyDocument::class : RelatedSecuredDummy::class, + $this->isMongoDB() ? RelatedLinkedDummyDocument::class : RelatedLinkedDummy::class, + ]); + } + + private function newSecuredDummy(): object + { + $class = $this->isMongoDB() ? SecuredDummyDocument::class : SecuredDummy::class; + + return new $class(); + } + + private function newRelatedDummy(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function newRelatedSecuredDummy(): object + { + $class = $this->isMongoDB() ? RelatedSecuredDummyDocument::class : RelatedSecuredDummy::class; + + return new $class(); + } + + private function newRelatedLinkedDummy(): object + { + $class = $this->isMongoDB() ? RelatedLinkedDummyDocument::class : RelatedLinkedDummy::class; + + return new $class(); + } + + private function seedSecuredDummies(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $d = $this->newSecuredDummy(); + $d->setTitle("#$i"); + $d->setDescription("Hello #$i"); + $d->setOwner('notexist'); + $manager->persist($d); + } + $manager->flush(); + } + + private function seedSecuredDummyWithOwner(string $owner, ?string $adminProperty = null, ?string $ownerProperty = null): void + { + $manager = $this->getManager(); + $d = $this->newSecuredDummy(); + $d->setTitle('#1'); + $d->setDescription('Hello #1'); + $d->setOwner($owner); + if (null !== $adminProperty) { + $d->setAdminOnlyProperty($adminProperty); + } + if (null !== $ownerProperty) { + $d->setOwnerOnlyProperty($ownerProperty); + } + $manager->persist($d); + $manager->flush(); + } + + private function seedSecuredDummiesWithRelations(int $count, string $owner): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $secured = $this->newSecuredDummy(); + $secured->setTitle("#$i"); + $secured->setDescription("Hello #$i"); + $secured->setOwner($owner); + + $related = $this->newRelatedDummy(); + $related->setName('RelatedDummy'); + $manager->persist($related); + + $relatedSecured = $this->newRelatedSecuredDummy(); + $manager->persist($relatedSecured); + + $publicRelated = $this->newRelatedSecuredDummy(); + $manager->persist($publicRelated); + + $linked = $this->newRelatedLinkedDummy(); + $manager->persist($linked); + + $secured->addRelatedDummy($related); + $secured->setRelatedDummy($related); + $secured->addRelatedSecuredDummy($relatedSecured); + $secured->setRelatedSecuredDummy($relatedSecured); + $secured->addPublicRelatedSecuredDummy($publicRelated); + $secured->setPublicRelatedSecuredDummy($publicRelated); + $linked->setSecuredDummy($secured); + + $manager->persist($secured); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/CollectionTest.php b/tests/Functional/GraphQl/CollectionTest.php new file mode 100644 index 00000000000..f47fb861f76 --- /dev/null +++ b/tests/Functional/GraphQl/CollectionTest.php @@ -0,0 +1,924 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositePrimitiveItem as CompositePrimitiveItemDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\VideoGame as VideoGameDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ThirdLevel::class, + DummyGroup::class, + DummyCustomQuery::class, + DummyDifferentGraphQlSerializationGroup::class, + Foo::class, + FooDummy::class, + SoMany::class, + MusicGroup::class, + VideoGame::class, + CompositeRelation::class, + CompositeItem::class, + CompositeLabel::class, + CompositePrimitiveItem::class, + ]; + } + + public function testRetrieveCollectionWithRelations(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummyAndThirdLevel(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + ...dummyFields + } + } + fragment dummyFields on DummyCursorConnection { + edges { + node { + id + name + relatedDummy { + name + thirdLevel { id level } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #3', $edges[2]['node']['name']); + $this->assertSame('RelatedDummy #3', $edges[2]['node']['relatedDummy']['name']); + $this->assertSame(3, $edges[2]['node']['relatedDummy']['thirdLevel']['level']); + } + + public function testRetrieveEmptyCollection(): void + { + $this->recreateDummiesAndRelated(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name } } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(0, $data['edges']); + $this->assertNull($data['pageInfo']['endCursor']); + $this->assertNull($data['pageInfo']['startCursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + } + + public function testRetrieveCollectionWithNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(4, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { + node { + name + relatedDummies { + edges { node { name } } + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #3', $edges[2]['node']['name']); + $this->assertSame('RelatedDummy23', $edges[2]['node']['relatedDummies']['edges'][1]['node']['name']); + } + + public function testRetrieveInverseSideNestedCollection(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? VideoGameDocument::class : VideoGame::class, + $this->isMongoDB() ? MusicGroupDocument::class : MusicGroup::class, + ]); + $this->seedVideoGameWithMusicGroups(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + musicGroups { + edges { + node { + name + videoGames { edges { node { name } } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['musicGroups']['edges']; + $this->assertSame('Sum 41', $edges[0]['node']['name']); + $this->assertSame('Guitar Hero', $edges[0]['node']['videoGames']['edges'][0]['node']['name']); + $this->assertSame('Franz Ferdinand', $edges[1]['node']['name']); + $this->assertSame('Guitar Hero', $edges[1]['node']['videoGames']['edges'][0]['node']['name']); + } + + public function testRetrieveCollectionAndItemTogether(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class, + $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class, + ]); + $this->seedDummiesWithDate(3); + $this->seedDummyGroups(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name dummyDate } } + } + dummyGroup(id: "/dummy_groups/2") { + foo + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertSame('Dummy #2', $data['dummies']['edges'][1]['node']['name']); + $this->assertSame('2015-04-02', $data['dummies']['edges'][1]['node']['dummyDate']); + $this->assertSame('Foo #2', $data['dummyGroup']['foo']); + } + + public function testFirstNItems(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2) { + edges { node { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertCount(2, $response->toArray()['data']['dummies']['edges']); + } + + public function testFirstNItemsOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(2, 5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 1) { + edges { + node { + name + relatedDummies(first: 2) { + edges { node { name } } + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertCount(2, $edges[0]['node']['relatedDummies']['edges']); + } + + public function testPaginationCursorsForward(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2) { + edges { cursor node { name } } + totalCount + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame(4, $data['totalCount']); + $this->assertSame('MQ==', $data['pageInfo']['endCursor']); + $this->assertTrue($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + $this->assertSame('MQ==', $data['edges'][1]['cursor']); + $this->assertSame('Dummy #2', $data['edges'][1]['node']['name']); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2, after: "MQ==") { + edges { cursor node { name } } + pageInfo { endCursor hasNextPage } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame('Dummy #3', $data['edges'][0]['node']['name']); + $this->assertSame('Mg==', $data['edges'][0]['cursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + } + + public function testPaginationCursorsBackward(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(last: 2) { + edges { cursor node { name } } + totalCount + pageInfo { startCursor hasPreviousPage hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame(4, $data['totalCount']); + $this->assertSame('Mg==', $data['pageInfo']['startCursor']); + $this->assertTrue($data['pageInfo']['hasPreviousPage']); + $this->assertSame('Dummy #4', $data['edges'][1]['node']['name']); + $this->assertSame('Mw==', $data['edges'][1]['cursor']); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(last: 2, before: "Mw==") { + edges { cursor node { name } } + pageInfo { startCursor hasPreviousPage } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame('Dummy #2', $data['edges'][0]['node']['name']); + $this->assertSame('MQ==', $data['edges'][0]['cursor']); + } + + public function testSoManyPartialPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('SoMany scenario @!mongodb'); + } + $this->recreateSchema([SoMany::class]); + $this->seedSoManies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + soManies(first: 2) { + edges { cursor node { content } } + totalCount + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['soManies']; + $this->assertSame('MA==', $data['pageInfo']['startCursor']); + $this->assertSame('MQ==', $data['pageInfo']['endCursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + $this->assertSame(0, $data['totalCount']); + $this->assertSame('Many #2', $data['edges'][1]['node']['content']); + $this->assertSame('MQ==', $data['edges'][1]['cursor']); + } + + public function testCollectionWithPaginationDisabled(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + $this->seedFoosWithFakeNames(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + foos { + id + name + bar + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $foos = $response->toArray()['data']['foos']; + $this->assertSame('/foos/4', $foos[3]['id']); + $this->assertSame('Separativeness', $foos[3]['name']); + $this->assertSame('Sit', $foos[3]['bar']); + } + + public function testCustomCollectionQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionDummyCustomQueries { + edges { node { message } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testCollectionDummyCustomQueries' => [ + 'edges' => [ + ['node' => ['message' => 'Success!']], + ['node' => ['message' => 'Success!']], + ], + ], + ], + ], $response->toArray()); + } + + public function testCustomCollectionQueryReadAndSerializeFalse(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionNoReadAndSerializeDummyCustomQueries { + edges { node { message } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['testCollectionNoReadAndSerializeDummyCustomQueries' => ['edges' => []]], + ], $response->toArray()); + } + + public function testCustomCollectionQueryWithCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionCustomArgumentsDummyCustomQueries(customArgumentString: "A string") { + edges { node { message customArgs } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testCollectionCustomArgumentsDummyCustomQueries' => [ + 'edges' => [ + ['node' => ['message' => 'Success!', 'customArgs' => ['customArgumentString' => 'A string']]], + ['node' => ['message' => 'Success!', 'customArgs' => ['customArgumentString' => 'A string']]], + ], + ], + ], + ], $response->toArray()); + } + + public function testRetrieveCompositePrimitiveIdentifierItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositePrimitiveItem::class]); + $manager = $this->getManager(); + $foo = new CompositePrimitiveItem('Foo', 2016); + $foo->setDescription('This is foo.'); + $manager->persist($foo); + $bar = new CompositePrimitiveItem('Bar', 2017); + $bar->setDescription('This is bar.'); + $manager->persist($bar); + $manager->flush(); + $manager->clear(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + compositePrimitiveItem(id: "/composite_primitive_items/name=Bar;year=2017") { + description + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('This is bar.', $response->toArray()['data']['compositePrimitiveItem']['description']); + } + + public function testRetrieveCompositeIdentifierItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + compositeRelation(id: "/composite_relations/compositeItem=1;compositeLabel=1") { + value + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('somefoobardummy', $response->toArray()['data']['compositeRelation']['value']); + } + + public function testCollectionWithNameConverter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name_converted } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'Converted 2', + $response->toArray()['data']['dummies']['edges'][1]['node']['name_converted'], + ); + } + + public function testCollectionWithDifferentSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class]); + $this->seedDummyDifferentGroups(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDifferentGraphQlSerializationGroups { + edges { node { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummyDifferentGraphQlSerializationGroups']['edges']; + $this->assertCount(3, $edges); + $this->assertArrayHasKey('name', $edges[0]['node']); + $this->assertArrayNotHasKey('title', $edges[0]['node']); + } + + public function testPageBasedPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1) { + collection { id name } + paginationInfo { itemsPerPage lastPage totalCount hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(3, $data['collection']); + $this->assertSame(3, $data['paginationInfo']['itemsPerPage']); + $this->assertSame(2, $data['paginationInfo']['lastPage']); + $this->assertSame(5, $data['paginationInfo']['totalCount']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 3) { collection { id name } } } + QUERY); + $this->assertCount(0, $response->toArray()['data']['fooDummies']['collection']); + } + + public function testPageBasedPaginationWithItemsPerPage(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 1, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 3, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(1, $response->toArray()['data']['fooDummies']['collection']); + } + + public function testMixedPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1) { + collection { + id name + soManies(first: 2) { + edges { cursor node { content } } + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + paginationInfo { itemsPerPage lastPage totalCount hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(3, $data['collection']); + $this->assertCount(2, $data['collection'][2]['soManies']['edges']); + $this->assertSame('So many 1', $data['collection'][2]['soManies']['edges'][1]['node']['content']); + $this->assertSame('MA==', $data['collection'][2]['soManies']['pageInfo']['startCursor']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + } + + public function testPaginationOnlyHasNextPage(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1, itemsPerPage: 2) { + collection { + id name + soManies(first: 2) { + edges { node { content } cursor } + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + paginationInfo { hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(2, $data['collection']); + $this->assertArrayHasKey('id', $data['collection'][1]); + $this->assertArrayHasKey('name', $data['collection'][1]); + $this->assertCount(2, $data['collection'][1]['soManies']['edges']); + $this->assertSame('So many 1', $data['collection'][1]['soManies']['edges'][1]['node']['content']); + $this->assertSame('MA==', $data['collection'][1]['soManies']['pageInfo']['startCursor']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2) { paginationInfo { hasNextPage } } } + QUERY); + $this->assertFalse($response->toArray()['data']['fooDummies']['paginationInfo']['hasNextPage']); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function newThirdLevel(): object + { + $class = $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class; + + return new $class(); + } + + private function seedDummies(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithDate(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + if ($count !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesEachWithRelatedDummies(int $count, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + + for ($j = 1; $j <= $nbRelated; ++$j) { + $related = $this->newRelated(); + $related->setName('RelatedDummy'.$j.$i); + $related->setAge((int) ($j.$i)); + $manager->persist($related); + $dummy->addRelatedDummy($related); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithRelatedDummyAndThirdLevel(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $third = $this->newThirdLevel(); + + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + $related->setThirdLevel($third); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setRelatedDummy($related); + + $manager->persist($third); + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummyGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $g = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $p) { + $g->{$p} = ucfirst($p).' #'.$i; + } + $manager->persist($g); + } + $manager->flush(); + } + + private function seedDummyCustomQuery(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class; + for ($i = 1; $i <= $count; ++$i) { + $manager->persist(new $class()); + } + $manager->flush(); + } + + private function seedDummyDifferentGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $d = new $class(); + $d->setName('Name #'.$i); + $d->setTitle('Title #'.$i); + $manager->persist($d); + } + $manager->flush(); + } + + private function seedFoosWithFakeNames(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? FooDocument::class : Foo::class; + $names = ['Hawsepipe', 'Sthenelus', 'Ephesian', 'Separativeness', 'Balbo']; + $bars = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + for ($i = 0; $i < $count; ++$i) { + $foo = new $class(); + $foo->setName($names[$i]); + $foo->setBar($bars[$i]); + $manager->persist($foo); + } + $manager->flush(); + } + + private function seedFooDummies(int $count): void + { + $manager = $this->getManager(); + $fooClass = $this->isMongoDB() ? FooDummyDocument::class : FooDummy::class; + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $soManyClass = $this->isMongoDB() ? SoManyDocument::class : SoMany::class; + $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; + $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + + for ($i = 0; $i < $count; ++$i) { + $dummy = new $dummyClass(); + $dummy->setName($dummies[$i]); + + $foo = new $fooClass(); + $foo->setName($names[$i]); + $foo->setDummy($dummy); + for ($j = 0; $j < 3; ++$j) { + $soMany = new $soManyClass(); + $soMany->content = "So many $j"; + $soMany->fooDummy = $foo; + $foo->soManies->add($soMany); + } + $manager->persist($foo); + } + $manager->flush(); + } + + private function seedSoManies(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? SoManyDocument::class : SoMany::class; + for ($i = 1; $i <= $count; ++$i) { + $s = new $class(); + $s->content = 'Many #'.$i; + $manager->persist($s); + } + $manager->flush(); + } + + private function seedVideoGameWithMusicGroups(): void + { + $manager = $this->getManager(); + $musicClass = $this->isMongoDB() ? MusicGroupDocument::class : MusicGroup::class; + $videoClass = $this->isMongoDB() ? VideoGameDocument::class : VideoGame::class; + + $sum41 = new $musicClass(); + $sum41->name = 'Sum 41'; + $manager->persist($sum41); + + $franz = new $musicClass(); + $franz->name = 'Franz Ferdinand'; + $manager->persist($franz); + + $videoGame = new $videoClass(); + $videoGame->name = 'Guitar Hero'; + $videoGame->addMusicGroup($sum41); + $videoGame->addMusicGroup($franz); + $manager->persist($videoGame); + $manager->flush(); + } + + private function seedCompositeIdentifierObjects(): void + { + $manager = $this->getManager(); + $item = new CompositeItem(); + $item->setField1('foobar'); + $manager->persist($item); + $manager->flush(); + + for ($i = 0; $i < 4; ++$i) { + $label = new CompositeLabel(); + $label->setValue('foo-'.$i); + $manager->persist($label); + $manager->flush(); + + $rel = new CompositeRelation(); + $rel->setCompositeLabel($label); + $rel->setCompositeItem($item); + $rel->setValue('somefoobardummy'); + $manager->persist($rel); + } + $manager->flush(); + $manager->clear(); + } +} diff --git a/tests/Functional/GraphQl/CustomTypeTest.php b/tests/Functional/GraphQl/CustomTypeTest.php new file mode 100644 index 00000000000..6fb33832662 --- /dev/null +++ b/tests/Functional/GraphQl/CustomTypeTest.php @@ -0,0 +1,134 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomTypeTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $resource = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $this->recreateSchema([$resource]); + $this->seedDummies($resource); + } + + public function testQueryFieldWithCustomType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + dummyDate + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2015-04-01', $response->toArray()['data']['dummy']['dummyDate']); + } + + public function testMutationInputWithCustomType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateDummy(input: {id: "/dummies/1", dummyDate: "2019-05-24T00:00:00+00:00"}) { + dummy { + dummyDate + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2019-05-24', $response->toArray()['data']['updateDummy']['dummy']['dummyDate']); + } + + public function testMutationVariableWithCustomType(): void + { + $response = $this->executeGraphQl( + <<<'QUERY' + mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { + updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { + dummy { + dummyDate + } + } + } + QUERY, + ['itemId' => '/dummies/1', 'itemDate' => '2017-11-14T00:00:00+00:00'], + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2017-11-14', $response->toArray()['data']['updateDummy']['dummy']['dummyDate']); + } + + public function testMutationVariableWithCustomTypeAndBadValue(): void + { + $response = $this->executeGraphQl( + <<<'QUERY' + mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { + updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { + dummy { + dummyDate + } + } + } + QUERY, + ['itemId' => '/dummies/1', 'itemDate' => 'bad date'], + ); + + $this->assertResponseIsSuccessful(); + $message = $response->toArray(false)['errors'][0]['message'] ?? ''; + $this->assertStringContainsString('Variable "$itemDate" got invalid value "bad date";', $message); + $this->assertStringContainsString('DateTime cannot represent non date value: "bad date"', $message); + } + + private function seedDummies(string $resourceClass): void + { + $manager = $this->getManager(); + $dummy1 = new $resourceClass(); + $dummy1->setName('Dummy #1'); + $dummy1->setAlias('Alias #1'); + $dummy1->setDescription('Smart dummy.'); + $dummy1->setDummyDate(new \DateTime('2015-04-01', new \DateTimeZone('UTC'))); + $manager->persist($dummy1); + + $dummy2 = new $resourceClass(); + $dummy2->setName('Dummy #2'); + $dummy2->setAlias('Alias #0'); + $dummy2->setDescription('Not so smart dummy.'); + $manager->persist($dummy2); + + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/DocsTest.php b/tests/Functional/GraphQl/DocsTest.php new file mode 100644 index 00000000000..f2bc0c20407 --- /dev/null +++ b/tests/Functional/GraphQl/DocsTest.php @@ -0,0 +1,29 @@ + + * + * 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\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + +final class DocsTest extends ApiTestCase +{ + protected static ?bool $alwaysBootKernel = false; + + public function testRetrieveGraphiQlDocumentation(): void + { + self::createClient()->request('GET', '/graphql', ['headers' => ['Accept' => 'text/html']]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'text/html; charset=UTF-8'); + } +} diff --git a/tests/Functional/GraphQl/FilterTest.php b/tests/Functional/GraphQl/FilterTest.php new file mode 100644 index 00000000000..a7d40722d66 --- /dev/null +++ b/tests/Functional/GraphQl/FilterTest.php @@ -0,0 +1,528 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; + +final class FilterTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ConvertedOwner::class, + ConvertedRelated::class, + DummyCar::class, + DummyCarColor::class, + ]; + } + + public function testBooleanFilter(): void + { + $this->recreateDummiesAndRelated(); + $manager = $this->getManager(); + $true = $this->newDummy(); + $true->setName('Dummy #1'); + $true->setDummyBoolean(true); + $manager->persist($true); + + $false = $this->newDummy(); + $false->setName('Dummy #2'); + $false->setDummyBoolean(false); + $manager->persist($false); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(dummyBoolean: false) { + edges { node { id dummyBoolean } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertFalse($edges[0]['node']['dummyBoolean']); + } + + public function testExistsFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(3); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(exists: [{relatedDummy: true}]) { + edges { + node { + id + relatedDummy { name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(2, $edges); + $this->assertArrayHasKey('name', $edges[0]['node']['relatedDummy']); + } + + public function testDateFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithDate(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(dummyDate: [{after: "2015-04-02"}]) { + edges { node { id dummyDate } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('2015-04-02', $edges[0]['node']['dummyDate']); + } + + public function testSearchFilterOnName(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(10); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name: "#2") { + edges { node { id name } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('/dummies/2', $edges[0]['node']['id']); + } + + public function testSearchFilterWithIntOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(4, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name: "Dummy #1") { + totalCount + edges { + node { + name + relatedDummies(age: 31) { + totalCount + edges { + node { id name age } + } + } + } + } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertSame(1, $data['totalCount']); + $this->assertSame(1, $data['edges'][0]['node']['relatedDummies']['totalCount']); + $this->assertSame('31', (string) $data['edges'][0]['node']['relatedDummies']['edges'][0]['node']['age']); + } + + public function testSearchFilterWithNameConverter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(10); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name_converted: "Converted 2") { + edges { node { id name name_converted } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('/dummies/2', $edges[0]['node']['id']); + $this->assertSame('Converted 2', $edges[0]['node']['name_converted']); + } + + public function testSearchFilterWithNameConverterOnNestedProperty(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class, + $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class, + ]); + $this->seedConvertedOwners(20); + + $response = $this->executeGraphQl(<<<'QUERY' + { + convertedOwners(name_converted__name_converted: "Converted 2") { + edges { + node { + id + name_converted { name_converted } + } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['convertedOwners']['edges']; + $this->assertCount(2, $edges); + $this->assertSame('/converted_owners/2', $edges[0]['node']['id']); + $this->assertSame('Converted 2', $edges[0]['node']['name_converted']['name_converted']); + $this->assertSame('/converted_owners/20', $edges[1]['node']['id']); + $this->assertSame('Converted 20', $edges[1]['node']['name_converted']['name_converted']); + } + + public function testSearchFilterOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(3, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { + node { + id + relatedDummies(name: "RelatedDummy13") { + edges { node { id name } } + } + } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(0, $edges[0]['node']['relatedDummies']['edges']); + $this->assertCount(0, $edges[1]['node']['relatedDummies']['edges']); + $this->assertCount(1, $edges[2]['node']['relatedDummies']['edges']); + $this->assertSame('RelatedDummy13', $edges[2]['node']['relatedDummies']['edges'][0]['node']['name']); + } + + public function testNestedCollectionFilter(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class, + $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class, + ]); + $this->seedDummyCarWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyCar(id: "/dummy_cars/1") { + id + colors(prop: "blue") { + edges { node { id prop } } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummyCar']['colors']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('blue', $edges[0]['node']['prop']); + } + + public function testRelatedSearchFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(1, 2); + $this->seedDummiesEachWithRelatedDummies(1, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(relatedDummies__name: "RelatedDummy31") { + edges { node { id } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response->toArray()['data']['dummies']['edges']); + } + + public function testOrderByNestedProperty(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(order: [{relatedDummy__name: "DESC"}]) { + edges { + node { + name + relatedDummy { id name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #2', $edges[0]['node']['name']); + $this->assertSame('Dummy #1', $edges[1]['node']['name']); + } + + public function testMultiKeyOrderRespectsArgumentOrder(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithSimilarProperties(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(order: [{description: "ASC"}, {name: "ASC"}]) { + edges { + node { id name description } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('baz', $edges[0]['node']['name']); + $this->assertSame('bar', $edges[0]['node']['description']); + $this->assertSame('foo', $edges[1]['node']['name']); + $this->assertSame('bar', $edges[1]['node']['description']); + } + + public function testRelatedSearchFilterMultiValueExact(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(relatedDummy__name_list: ["RelatedDummy #1", "RelatedDummy #2"]) { + edges { + node { + id + name + relatedDummy { name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(2, $edges); + $this->assertSame('RelatedDummy #1', $edges[0]['node']['relatedDummy']['name']); + $this->assertSame('RelatedDummy #2', $edges[1]['node']['relatedDummy']['name']); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummies(int $count): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithDate(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + if ($count !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesEachWithRelatedDummies(int $count, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + + for ($j = 1; $j <= $nbRelated; ++$j) { + $related = $this->newRelated(); + $related->setName('RelatedDummy'.$j.$i); + $related->setAge((int) ($j.$i)); + $manager->persist($related); + + $dummy->addRelatedDummy($related); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithSimilarProperties(): void + { + $manager = $this->getManager(); + foreach ([ + ['foo', 'bar'], + ['baz', 'qux'], + ['foo', 'qux'], + ['baz', 'bar'], + ] as [$name, $description]) { + $dummy = $this->newDummy(); + $dummy->setName($name); + $dummy->setDescription($description); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedConvertedOwners(int $count): void + { + $relatedClass = $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class; + $ownerClass = $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->nameConverted = 'Converted '.$i; + + $owner = new $ownerClass(); + $owner->nameConverted = $related; + + $manager->persist($related); + $manager->persist($owner); + } + $manager->flush(); + } + + private function seedDummyCarWithColors(): void + { + $manager = $this->getManager(); + $carClass = $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class; + $colorClass = $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class; + + $car = new $carClass(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + + if (\is_object($car->getId())) { + $manager->persist($car->getId()); + $manager->flush(); + } + + $red = new $colorClass(); + $red->setProp('red'); + $red->setCar($car); + $manager->persist($red); + $manager->flush(); + + $blue = new $colorClass(); + $blue->setProp('blue'); + $blue->setCar($car); + $manager->persist($blue); + $manager->flush(); + + $car->setColors(new ArrayCollection([$red, $blue])); + $manager->persist($car); + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/InputOutputTest.php b/tests/Functional/GraphQl/InputOutputTest.php new file mode 100644 index 00000000000..a120f54edda --- /dev/null +++ b/tests/Functional/GraphQl/InputOutputTest.php @@ -0,0 +1,236 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoInputOutput as DummyDtoInputOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MessengerWithInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + DummyDtoInputOutput::class, + DummyDtoNoOutput::class, + DummyDtoNoInput::class, + MessengerWithInput::class, + RelatedDummy::class, + ]; + } + + public function testRetrieveOutputAfterRestCreation(): void + { + $this->recreateSchema($this->resolveResources([ + DummyDtoInputOutput::class => DummyDtoInputOutputDocument::class, + RelatedDummy::class => RelatedDummyDocument::class, + ])); + $this->seedRelatedDummy(); + + $client = self::createClient(); + $client->request('POST', '/dummy_dto_input_outputs', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'test', 'bar' => 1, 'relatedDummies' => ['/related_dummies/1']], + ]); + $this->assertResponseStatusCodeSame(201); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { + _id, id, baz, + relatedDummies { + edges { + node { + name + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'dummyDtoInputOutput' => [ + '_id' => 1, + 'id' => '/dummy_dto_input_outputs/1', + 'baz' => 1, + 'relatedDummies' => [ + 'edges' => [ + ['node' => ['name' => 'RelatedDummy with friends']], + ], + ], + ], + ], + ], $response->toArray()); + } + + public function testCreateItemWithCustomInputAndOutput(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoInputOutput::class => DummyDtoInputOutputDocument::class])); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoInputOutput(input: {foo: "A foo", bar: 4, clientMutationId: "myId"}) { + dummyDtoInputOutput { + baz, + bat + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'createDummyDtoInputOutput' => [ + 'dummyDtoInputOutput' => ['baz' => 4, 'bat' => 'A foo'], + 'clientMutationId' => 'myId', + ], + ], + ], $response->toArray()); + } + + public function testCreateItemWithDisabledOutputClassFailsToQueryFields(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoNoOutput::class => DummyDtoNoOutputDocument::class])); + $this->seedDummyDtoNoOutput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { + dummyDtoNoOutput { + id + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame('Cannot query field "id" on type "DummyDtoNoOutput".', $data['errors'][0]['message']); + $this->assertSame(4, $data['errors'][0]['locations'][0]['line']); + $this->assertSame(7, $data['errors'][0]['locations'][0]['column']); + } + + public function testCreateItemWithDisabledInputClassRejectsUndefinedFields(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoNoInput::class => DummyDtoNoInputDocument::class])); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertMatchesRegularExpression( + '/^Field "lorem" is not defined by type "?createDummyDtoNoInputInput"?\.$/', + $data['errors'][0]['message'], + ); + $this->assertMatchesRegularExpression( + '/^Field "ipsum" is not defined by type "?createDummyDtoNoInputInput"?\.$/', + $data['errors'][1]['message'], + ); + } + + public function testMessengerWithInputReturnsSynchronousResult(): void + { + // MessengerWithInput is not a Doctrine resource — nothing to recreate. + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createMessengerWithInput(input: {var: "test"}) { + messengerWithInput { id, name } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'createMessengerWithInput' => [ + 'messengerWithInput' => [ + 'id' => '/messenger_with_inputs/1', + 'name' => 'test', + ], + ], + ], + ], $response->toArray()); + } + + /** + * @param array $map + * + * @return list + */ + private function resolveResources(array $map): array + { + $resolved = []; + foreach ($map as $entity => $document) { + $resolved[] = $this->isMongoDB() ? $document : $entity; + } + + return $resolved; + } + + private function seedRelatedDummy(): void + { + $resourceClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $manager = $this->getManager(); + $related = new $resourceClass(); + $related->setName('RelatedDummy with friends'); + $manager->persist($related); + $manager->flush(); + $manager->clear(); + } + + private function seedDummyDtoNoOutput(int $count): void + { + $resourceClass = $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dto = new $resourceClass(); + $dto->lorem = 'DummyDtoNoOutput foo #'.$i; + $dto->ipsum = (string) ($i / 3); + $manager->persist($dto); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/IntrospectionTest.php b/tests/Functional/GraphQl/IntrospectionTest.php new file mode 100644 index 00000000000..b7827b5cc7b --- /dev/null +++ b/tests/Functional/GraphQl/IntrospectionTest.php @@ -0,0 +1,487 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DeprecatedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyInspection; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class IntrospectionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyProduct::class, + DummyAggregateOffer::class, + DummyDifferentGraphQlSerializationGroup::class, + DummyGroup::class, + DummyProperty::class, + DeprecatedResource::class, + VoDummyCar::class, + VoDummyInspection::class, + Person::class, + VideoGame::class, + ]; + } + + public function testEmptyQueryReturnsBadRequest(): void + { + $client = self::createClient(); + $client->request('GET', '/graphql'); + + $this->assertResponseStatusCodeSame(200); + $data = $client->getResponse()->toArray(false); + $this->assertSame(400, $data['errors'][0]['extensions']['status']); + $this->assertSame('GraphQL query is not valid.', $data['errors'][0]['message']); + } + + public function testIntrospectSchema(): void + { + $response = $this->introspectSchema(); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayHasKey('types', $data['data']['__schema']); + $this->assertSame('Query', $data['data']['__schema']['queryType']['name']); + $this->assertSame('Mutation', $data['data']['__schema']['mutationType']['name']); + } + + public function testIntrospectTypes(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + type1: __type(name: "DummyProduct") { + description, + fields { name type { name kind ofType { name kind } } } + } + type2: __type(name: "DummyAggregateOfferCursorConnection") { + description, + fields { name type { name kind ofType { name kind } } } + } + type3: __type(name: "DummyAggregateOfferEdge") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame('Dummy Product.', $data['type1']['description']); + $this->assertContainsEquals( + ['name' => 'offers', 'type' => ['name' => 'DummyAggregateOfferCursorConnection', 'kind' => 'OBJECT', 'ofType' => null]], + $data['type1']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'edges', 'type' => ['name' => null, 'kind' => 'LIST', 'ofType' => ['name' => 'DummyAggregateOfferEdge', 'kind' => 'OBJECT']]], + $data['type2']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'node', 'type' => ['name' => 'DummyAggregateOffer', 'kind' => 'OBJECT', 'ofType' => null]], + $data['type3']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'cursor', 'type' => ['name' => null, 'kind' => 'NON_NULL', 'ofType' => ['name' => 'String', 'kind' => 'SCALAR']]], + $data['type3']['fields'], + ); + } + + public function testIntrospectTypesWithDifferentSerializationGroups(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + type1: __type(name: "DummyDifferentGraphQlSerializationGroupCollection") { + description, + fields { name type { name kind ofType { name kind } } } + } + type2: __type(name: "DummyDifferentGraphQlSerializationGroupItem") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame( + 'Dummy with different serialization groups for item_query and collection_query.', + $data['type1']['description'], + ); + $this->assertCount(3, $data['type1']['fields']); + $this->assertSame('title', $data['type2']['fields'][3]['name']); + } + + public function testIntrospectDeprecatedQueries(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type (name: "Query") { + name + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertGraphQlFieldDeprecated($data, 'deprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'deprecatedResources', 'This resource is deprecated'); + } + + public function testIntrospectDeprecatedMutations(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type (name: "Mutation") { + name + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertGraphQlFieldDeprecated($data, 'deleteDeprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'updateDeprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'createDeprecatedResource', 'This resource is deprecated'); + } + + public function testIntrospectDeprecatedField(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "DeprecatedResource") { + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertGraphQlFieldDeprecated($response->toArray(), 'deprecatedField', 'This field is deprecated'); + } + + public function testRetrieveRelayNodeInterface(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Node") { + name + kind + fields { + name + type { + kind + ofType { name kind } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + '__type' => [ + 'name' => 'Node', + 'kind' => 'INTERFACE', + 'fields' => [ + [ + 'name' => 'id', + 'type' => ['kind' => 'NON_NULL', 'ofType' => ['name' => 'ID', 'kind' => 'SCALAR']], + ], + ], + ], + ], + ], $response->toArray()); + } + + public function testRetrieveRelayNodeField(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __schema { + queryType { + fields { + name + type { name kind } + args { name type { kind ofType { name kind } } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $fields = $response->toArray()['data']['__schema']['queryType']['fields']; + $this->assertSame('node', $fields[0]['name']); + $this->assertSame('Node', $fields[0]['type']['name']); + $this->assertSame('INTERFACE', $fields[0]['type']['kind']); + $this->assertSame('id', $fields[0]['args'][0]['name']); + $this->assertSame('NON_NULL', $fields[0]['args'][0]['type']['kind']); + $this->assertSame('ID', $fields[0]['args'][0]['type']['ofType']['name']); + $this->assertSame('SCALAR', $fields[0]['args'][0]['type']['ofType']['kind']); + } + + public function testIntrospectIterableFieldOnDummy(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Dummy") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertContainsEquals( + ['name' => 'jsonData', 'type' => ['name' => 'Iterable', 'kind' => 'SCALAR', 'ofType' => null]], + $response->toArray()['data']['__type']['fields'], + ); + } + + public function testRetrieveDummyGroupFieldsAndMutationInputs(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeQuery: __type(name: "DummyGroup") { + fields { name type { name kind ofType { name kind } } } + } + typeCreateInput: __type(name: "createDummyGroupInput") { + inputFields { name type { name kind ofType { name kind } } } + } + typeCreatePayload: __type(name: "createDummyGroupPayload") { + fields { name type { name kind ofType { name kind } } } + } + typeCreatePayloadData: __type(name: "createDummyGroupPayloadData") { + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertCount(2, $data['typeQuery']['fields']); + $this->assertSame('id', $data['typeQuery']['fields'][0]['name']); + $this->assertSame('foo', $data['typeQuery']['fields'][1]['name']); + + $this->assertCount(3, $data['typeCreateInput']['inputFields']); + $this->assertSame('bar', $data['typeCreateInput']['inputFields'][0]['name']); + $this->assertSame('baz', $data['typeCreateInput']['inputFields'][1]['name']); + $this->assertSame('clientMutationId', $data['typeCreateInput']['inputFields'][2]['name']); + + $this->assertCount(2, $data['typeCreatePayload']['fields']); + $this->assertSame('dummyGroup', $data['typeCreatePayload']['fields'][0]['name']); + $this->assertSame('createDummyGroupPayloadData', $data['typeCreatePayload']['fields'][0]['type']['name']); + $this->assertSame('clientMutationId', $data['typeCreatePayload']['fields'][1]['name']); + + $this->assertCount(2, $data['typeCreatePayloadData']['fields']); + $this->assertSame('id', $data['typeCreatePayloadData']['fields'][0]['name']); + $this->assertSame('bar', $data['typeCreatePayloadData']['fields'][1]['name']); + } + + public function testRetrieveNestedMutationPayloadData(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeCreatePayload: __type(name: "createDummyPropertyPayload") { + fields { name type { name kind ofType { name kind } } } + } + typeCreatePayloadData: __type(name: "createDummyPropertyPayloadData") { + fields { name type { name kind ofType { name kind } } } + } + typeCreateNestedPayload: __type(name: "createDummyGroupNestedPayload") { + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame([ + ['name' => 'dummyProperty', 'type' => ['name' => 'createDummyPropertyPayloadData', 'kind' => 'OBJECT', 'ofType' => null]], + ['name' => 'clientMutationId', 'type' => ['name' => 'String', 'kind' => 'SCALAR', 'ofType' => null]], + ], $data['typeCreatePayload']['fields']); + + $this->assertContainsEquals( + ['name' => 'group', 'type' => ['name' => 'createDummyGroupNestedPayload', 'kind' => 'OBJECT', 'ofType' => null]], + $data['typeCreatePayloadData']['fields'], + ); + + $this->assertContainsEquals( + ['name' => 'id', 'type' => ['name' => null, 'kind' => 'NON_NULL', 'ofType' => ['name' => 'ID', 'kind' => 'SCALAR']]], + $data['typeCreateNestedPayload']['fields'], + ); + } + + public function testRetrieveTypenameViaGraphQlQuery(): void + { + $resources = [ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]; + $this->recreateSchema($resources); + $this->seedDummiesWithRelatedDummy(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy: dummy(id: "/dummies/3") { + name + relatedDummy { + id + name + __typename + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('Dummy #3', $dummy['name']); + $this->assertSame('RelatedDummy #3', $dummy['relatedDummy']['name']); + $this->assertSame('RelatedDummy', $dummy['relatedDummy']['__typename']); + } + + public function testIntrospectTypeAvailableOnlyThroughRelations(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeNotAvailable: __type(name: "VoDummyInspectionCursorConnection") { + description + } + typeOwner: __type(name: "VoDummyCar") { + description, + fields { name type { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertNull($data['typeNotAvailable']); + $this->assertSame('VoDummyInspectionCursorConnection', $data['typeOwner']['fields'][1]['type']['name']); + } + + public function testIntrospectEnum(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + person: __type(name: "Person") { + name + fields { + name + type { + name + description + enumValues { name description } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $field = $response->toArray()['data']['person']['fields'][1]; + $this->assertSame('GenderTypeEnum', $field['type']['name']); + $this->assertSame('MALE', $field['type']['enumValues'][0]['name']); + $this->assertSame('FEMALE', $field['type']['enumValues'][1]['name']); + $this->assertSame('The female gender.', $field['type']['enumValues'][1]['description']); + } + + public function testIntrospectEnumResource(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + videoGame: __type(name: "VideoGame") { + name + fields { + name + type { name kind ofType { name kind } } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'GamePlayMode', + $response->toArray()['data']['videoGame']['fields'][3]['type']['ofType']['name'], + ); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $relatedClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $manager = $this->getManager(); + + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->setName('RelatedDummy #'.$i); + + $dummy = new $dummyClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/MutationTest.php b/tests/Functional/GraphQl/MutationTest.php new file mode 100644 index 00000000000..21eb9329680 --- /dev/null +++ b/tests/Functional/GraphQl/MutationTest.php @@ -0,0 +1,950 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomMutation as DummyCustomMutationDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\WritableId as WritableIdDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6354\ActivityLog; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomMutation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooEmbeddable; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WritableId; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\MediaObject; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\HttpFoundation\File\UploadedFile; + +final class MutationTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private const FIXTURES_DIR = __DIR__.'/../../../features/files'; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Foo::class, + Dummy::class, + RelatedDummy::class, + Person::class, + FooDummy::class, + FooEmbeddable::class, + CompositeRelation::class, + CompositeItem::class, + CompositeLabel::class, + WritableId::class, + DummyGroup::class, + DummyCustomMutation::class, + ActivityLog::class, + GamePlayMode::class, + VideoGame::class, + ThirdLevel::class, + FourthLevel::class, + DummyFriend::class, + RelatedToDummyFriend::class, + MediaObject::class, + ]; + } + + public function testCreateItem(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createFoo(input: {name: "A new one", bar: "new", clientMutationId: "myId"}) { + foo { id _id __typename name bar } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['createFoo']; + $this->assertSame('/foos/1', $data['foo']['id']); + $this->assertSame(1, $data['foo']['_id']); + $this->assertSame('Foo', $data['foo']['__typename']); + $this->assertSame('A new one', $data['foo']['name']); + $this->assertSame('new', $data['foo']['bar']); + $this->assertSame('myId', $data['clientMutationId']); + } + + public function testCreateItemWithoutClientMutationId(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createFoo(input: {name: "Created without mutation id", bar: "works"}) { + foo { id name bar } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['createFoo']['foo']; + $this->assertSame('/foos/1', $data['id']); + $this->assertSame('Created without mutation id', $data['name']); + $this->assertSame('works', $data['bar']); + } + + public function testCreateItemWithRelationToExisting(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "A dummy", foo: [], relatedDummy: "/related_dummies/1", name_converted: "Converted" clientMutationId: "myId"}) { + dummy { + id + name + foo + relatedDummy { name __typename } + name_converted + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummy']; + $this->assertSame('/dummies/2', $d['dummy']['id']); + $this->assertSame('A dummy', $d['dummy']['name']); + $this->assertCount(0, $d['dummy']['foo']); + $this->assertSame('RelatedDummy #1', $d['dummy']['relatedDummy']['name']); + $this->assertSame('RelatedDummy', $d['dummy']['relatedDummy']['__typename']); + $this->assertSame('Converted', $d['dummy']['name_converted']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateItemWithIterableField(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "A dummy", foo: [], jsonData: {bar:{baz:3,qux:[7.6,false,null]}}, arrayData: ["bar", "baz"], clientMutationId: "myId"}) { + dummy { + id name foo jsonData arrayData + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummy']; + $this->assertSame('/dummies/1', $d['dummy']['id']); + $this->assertSame('A dummy', $d['dummy']['name']); + $this->assertSame(3, $d['dummy']['jsonData']['bar']['baz']); + $this->assertSame(7.6, $d['dummy']['jsonData']['bar']['qux'][0]); + $this->assertFalse($d['dummy']['jsonData']['bar']['qux'][1]); + $this->assertNull($d['dummy']['jsonData']['bar']['qux'][2]); + $this->assertSame('baz', $d['dummy']['arrayData'][1]); + } + + public function testCreateItemWithEnum(): void + { + $this->recreateSchema([$this->isMongoDB() ? PersonDocument::class : Person::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createPerson(input: {name: "Mob", genderType: FEMALE}) { + person { id name genderType } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $p = $response->toArray()['data']['createPerson']['person']; + $this->assertSame('/people/1', $p['id']); + $this->assertSame('Mob', $p['name']); + $this->assertSame('FEMALE', $p['genderType']); + } + + public function testCreateItemWithEnumCollection(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Enum collection scenario @!mongodb'); + } + $this->recreateSchema([Person::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createPerson(input: {name: "Harry", academicGrades: [BACHELOR, MASTER]}) { + person { id name genderType academicGrades } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $p = $response->toArray()['data']['createPerson']['person']; + $this->assertSame('/people/1', $p['id']); + $this->assertSame('Harry', $p['name']); + $this->assertCount(2, $p['academicGrades']); + $this->assertSame('BACHELOR', $p['academicGrades'][0]); + $this->assertSame('MASTER', $p['academicGrades'][1]); + } + + public function testDeleteItem(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? FooDocument::class : Foo::class; + $foo = new $class(); + $foo->setName('Existing'); + $foo->setBar('value'); + $manager->persist($foo); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteFoo(input: {id: "/foos/1", clientMutationId: "anotherId"}) { + foo { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['deleteFoo']; + $this->assertSame('/foos/1', $data['foo']['id']); + $this->assertSame('anotherId', $data['clientMutationId']); + } + + public function testDeleteWithWrongResourceTypeYieldsError(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? FooDocument::class : Foo::class, + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteFoo(input: {id: "/dummies/1", clientMutationId: "myId"}) { + foo { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'Item "/dummies/1" did not match expected type "Foo".', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testDeleteItemWithCompositeIdentifiers(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=1", clientMutationId: "myId"}) { + compositeRelation { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['deleteCompositeRelation']; + $this->assertSame('/composite_relations/compositeItem=1;compositeLabel=1', $data['compositeRelation']['id']); + $this->assertSame('myId', $data['clientMutationId']); + } + + public function testModifyItem(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateDummy(input: {id: "/dummies/1", description: "Modified description.", dummyDate: "2018-06-05T00:00:00+00:00", clientMutationId: "myId"}) { + dummy { id name description dummyDate } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateDummy']; + $this->assertSame('/dummies/1', $d['dummy']['id']); + $this->assertSame('Dummy #1', $d['dummy']['name']); + $this->assertSame('Modified description.', $d['dummy']['description']); + $this->assertSame('2018-06-05', $d['dummy']['dummyDate']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testModifyItemWithEmbeddedObject(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { + fooDummy { + id + name + embeddedFoo { dummyName } + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateFooDummy']; + $this->assertSame('modifiedName', $d['fooDummy']['name']); + $this->assertSame('Embedded name', $d['fooDummy']['embeddedFoo']['dummyName']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testModifyNonWritablePropertyRejected(): void + { + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", nonWritableProp: "written", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { + fooDummy { id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesRegularExpression( + '/^Field "nonWritableProp" is not defined by type "?updateFooDummyInput"?\.$/', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testModifyNonWritableEmbeddedPropertyRejected(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name", nonWritableProp: "written"}, clientMutationId: "myId"}) { + fooDummy { id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesRegularExpression( + '/^Field "nonWritableProp" is not defined by type "?FooEmbeddableNestedInput"?\.$/', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testModifyItemWithCompositeIdentifiers(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=2", value: "Modified value.", clientMutationId: "myId"}) { + compositeRelation { id value } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateCompositeRelation']; + $this->assertSame('/composite_relations/compositeItem=1;compositeLabel=2', $d['compositeRelation']['id']); + $this->assertSame('Modified value.', $d['compositeRelation']['value']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateWithCustomUuid(): void + { + $this->recreateSchema([$this->isMongoDB() ? WritableIdDocument::class : WritableId::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createWritableId(input: {_id: "c6b722fe-0331-48c4-a214-f81f9f1ca082", name: "Foo", clientMutationId: "m"}) { + writableId { id _id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createWritableId']; + $this->assertSame('/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082', $d['writableId']['id']); + $this->assertSame('c6b722fe-0331-48c4-a214-f81f9f1ca082', $d['writableId']['_id']); + $this->assertSame('Foo', $d['writableId']['name']); + $this->assertSame('m', $d['clientMutationId']); + } + + public function testUpdateWithCustomUuid(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('WritableId update @!mongodb'); + } + $this->recreateSchema([WritableId::class]); + $manager = $this->getManager(); + $w = new WritableId(); + $w->id = 'c6b722fe-0331-48c4-a214-f81f9f1ca082'; + $w->name = 'Foo'; + $manager->persist($w); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateWritableId(input: {id: "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082", _id: "f8a708b2-310f-416c-9aef-b1b5719dfa47", name: "Foo", clientMutationId: "m"}) { + writableId { id _id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateWritableId']; + $this->assertSame('/writable_ids/f8a708b2-310f-416c-9aef-b1b5719dfa47', $d['writableId']['id']); + $this->assertSame('f8a708b2-310f-416c-9aef-b1b5719dfa47', $d['writableId']['_id']); + $this->assertSame('Foo', $d['writableId']['name']); + } + + public function testUseSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + $g = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $p) { + $g->{$p} = ucfirst($p).' #1'; + } + $manager->persist($g); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyGroup(input: {bar: "Bar", baz: "Baz", clientMutationId: "myId"}) { + dummyGroup { id bar __typename } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummyGroup']; + $this->assertSame('/dummy_groups/2', $d['dummyGroup']['id']); + $this->assertSame('Bar', $d['dummyGroup']['bar']); + $this->assertSame('createDummyGroupPayloadData', $d['dummyGroup']['__typename']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testTriggerValidationError(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "", foo: [], clientMutationId: "myId"}) { + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame('422', (string) $data['errors'][0]['extensions']['status']); + $this->assertSame('name: This value should not be blank.', $data['errors'][0]['message']); + $this->assertArrayHasKey('violations', $data['errors'][0]['extensions']); + $this->assertSame('name', $data['errors'][0]['extensions']['violations'][0]['path']); + $this->assertSame('This value should not be blank.', $data['errors'][0]['extensions']['violations'][0]['message']); + } + + public function testCustomMutation(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + '8', + (string) $response->toArray()['data']['sumDummyCustomMutation']['dummyCustomMutation']['result'], + ); + } + + public function testCustomMutationNotPersisted(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumNotPersistedDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['sumNotPersistedDummyCustomMutation']['dummyCustomMutation']); + } + + public function testCustomMutationNoWriteCustomResult(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumNoWriteCustomResultDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + '1234', + (string) $response->toArray()['data']['sumNoWriteCustomResultDummyCustomMutation']['dummyCustomMutation']['result'], + ); + } + + public function testCustomMutationOnlyPersist(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumOnlyPersistDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['sumOnlyPersistDummyCustomMutation']['dummyCustomMutation']); + } + + public function testCustomMutationCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + testCustomArgumentsDummyCustomMutation(input: {operandC: 18, clientMutationId: "myId"}) { + dummyCustomMutation { result } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['testCustomArgumentsDummyCustomMutation']; + $this->assertSame('18', (string) $d['dummyCustomMutation']['result']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateItemWithEnumAsResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('VideoGame ORM-only.'); + } + + $this->recreateSchema([VideoGame::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + gamePlayModes { id name } + gamePlayMode(id: "/game_play_modes/SINGLE_PLAYER") { name } + } + QUERY); + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertCount(3, $data['gamePlayModes']); + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $data['gamePlayModes'][2]['id']); + $this->assertSame('SINGLE_PLAYER', $data['gamePlayModes'][2]['name']); + $this->assertSame('SINGLE_PLAYER', $data['gamePlayMode']['name']); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createVideoGame(input: {name: "Baten Kaitos", playMode: "/game_play_modes/SINGLE_PLAYER"}) { + videoGame { id name playMode { id name } } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $vg = $response->toArray()['data']['createVideoGame']['videoGame']; + $this->assertSame('/video_games/1', $vg['id']); + $this->assertSame('Baten Kaitos', $vg['name']); + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $vg['playMode']['id']); + $this->assertSame('SINGLE_PLAYER', $vg['playMode']['name']); + } + + public function testDeleteInvalidItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ActivityLog @!mongodb.'); + } + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteActivityLog(input: {id: "/activity_logs/1"}) { + activityLog { id } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $this->assertArrayHasKey('activityLog', $data['data']['deleteActivityLog']); + } + + public function testUploadFileWithCustomMutation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MediaObject @!mongodb.'); + } + + $file = new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true); + $response = $this->executeGraphQlMultipart( + '{"query": "mutation($file: Upload!) { uploadMediaObject(input: {file: $file}) { mediaObject { id contentUrl } } }", "variables": {"file": null}}', + '{"file": ["variables.file"]}', + ['file' => $file], + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test.gif', $response->toArray()['data']['uploadMediaObject']['mediaObject']['contentUrl']); + } + + public function testUploadMultipleFilesWithCustomMutation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MediaObject @!mongodb.'); + } + + $files = [ + '0' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + '1' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + '2' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + ]; + $response = $this->executeGraphQlMultipart( + '{"query": "mutation($files: [Upload!]!) { uploadMultipleMediaObject(input: {files: $files}) { mediaObject { id contentUrl } } }", "variables": {"files": [null, null, null]}}', + '{"0": ["variables.files.0"], "1": ["variables.files.1"], "2": ["variables.files.2"]}', + $files, + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test.gif', $response->toArray()['data']['uploadMultipleMediaObject']['mediaObject']['contentUrl']); + } + + public function testUseSerializationGroupsWithRelations(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FourthLevel + RelatedToDummyFriend @!mongodb.'); + } + + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class, + DummyFriend::class, RelatedToDummyFriend::class, + ]); + $this->seedDummyWithRelatedDummyAndThirdLevel(); + $this->seedRelatedDummyWithFriends(2); + $this->seedDummyWithFourthLevelRelation(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateRelatedDummy(input: { + id: "/related_dummies/2", + symfony: "laravel", + thirdLevel: { fourthLevel: "/fourth_levels/1" } + }) { + relatedDummy { + id symfony + thirdLevel { id fourthLevel { id __typename } __typename } + relatedToDummyFriend { + edges { node { name } } + __typename + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $rel = $response->toArray()['data']['updateRelatedDummy']['relatedDummy']; + $this->assertSame('/related_dummies/2', $rel['id']); + $this->assertSame('laravel', $rel['symfony']); + $this->assertSame('/third_levels/3', $rel['thirdLevel']['id']); + $this->assertSame('updateThirdLevelNestedPayload', $rel['thirdLevel']['__typename']); + $this->assertSame('/fourth_levels/1', $rel['thirdLevel']['fourthLevel']['id']); + $this->assertSame('updateFourthLevelNestedPayload', $rel['thirdLevel']['fourthLevel']['__typename']); + $this->assertSame('updateRelatedToDummyFriendNestedPayloadCursorConnection', $rel['relatedToDummyFriend']['__typename']); + $this->assertSame('Relation-1', $rel['relatedToDummyFriend']['edges'][0]['node']['name']); + $this->assertSame('Relation-2', $rel['relatedToDummyFriend']['edges'][1]['node']['name']); + } + + public function testMutationRunsBeforeValidation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ActivityLog @!mongodb.'); + } + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createActivityLog(input: {name: ""}) { + activityLog { name } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $this->assertSame('hi', $response->toArray()['data']['createActivityLog']['activityLog']['name']); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedFooDummyWithEmbeddable(): void + { + $manager = $this->getManager(); + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $fooClass = $this->isMongoDB() ? FooDummyDocument::class : FooDummy::class; + + $dummy = new $dummyClass(); + $dummy->setName('Lorem'); + + $foo = new $fooClass(); + $foo->setName('Hawsepipe'); + + $embedded = new FooEmbeddable(); + $embedded->setDummyName('embeddedHawsepipe'); + $foo->setEmbeddedFoo($embedded); + $foo->setDummy($dummy); + + $manager->persist($foo); + $manager->flush(); + } + + private function seedCompositeIdentifierObjects(): void + { + $manager = $this->getManager(); + $item = new CompositeItem(); + $item->setField1('foobar'); + $manager->persist($item); + $manager->flush(); + + for ($i = 0; $i < 4; ++$i) { + $label = new CompositeLabel(); + $label->setValue('foo-'.$i); + $manager->persist($label); + $manager->flush(); + + $rel = new CompositeRelation(); + $rel->setCompositeLabel($label); + $rel->setCompositeItem($item); + $rel->setValue('somefoobardummy'); + $manager->persist($rel); + } + $manager->flush(); + $manager->clear(); + } + + private function seedDummyCustomMutation(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class; + for ($i = 1; $i <= $count; ++$i) { + $m = new $class(); + $m->setOperandA(3); + $manager->persist($m); + } + $manager->flush(); + } + + private function seedDummyWithRelatedDummyAndThirdLevel(): void + { + $manager = $this->getManager(); + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy #1'); + $relatedDummy->setThirdLevel($thirdLevel); + $dummy = new Dummy(); + $dummy->setName('Dummy #1'); + $dummy->setAlias('Alias #0'); + $dummy->setRelatedDummy($relatedDummy); + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + $manager->persist($dummy); + $manager->flush(); + } + + private function seedRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + $manager->persist($relation); + } + + $other = new RelatedDummy(); + $other->setName('RelatedDummy without friends'); + $manager->persist($other); + $manager->flush(); + $manager->clear(); + } + + private function seedDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/QueryTest.php b/tests/Functional/GraphQl/QueryTest.php new file mode 100644 index 00000000000..78e1ccb96a7 --- /dev/null +++ b/tests/Functional/GraphQl/QueryTest.php @@ -0,0 +1,852 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6427\SecurityAfterResolver; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\WithJsonDummy as WithJsonDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNested; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNestedPaginated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsResolveDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TreeDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WithJsonDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; + +final class QueryTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + MultiRelationsDummy::class, + MultiRelationsRelatedDummy::class, + MultiRelationsResolveDummy::class, + MultiRelationsNested::class, + MultiRelationsNestedPaginated::class, + TreeDummy::class, + WithJsonDummy::class, + DummyGroup::class, + DummyCar::class, + DummyCarColor::class, + DummyDtoNoInput::class, + DummyDtoNoOutput::class, + DummyCustomQuery::class, + DummyDifferentGraphQlSerializationGroup::class, + SecurityAfterResolver::class, + Foo::class, + ]; + } + + public function testBasicQuery(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + id + name + name_converted + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('/dummies/1', $dummy['id']); + $this->assertSame('Dummy #1', $dummy['name']); + $this->assertSame('Converted 1', $dummy['name_converted']); + } + + public function testQueryWithDifferentRelationsToSameResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 1, 2, 3, 4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneRelation { id name } + manyToOneResolveRelation { id name } + manyToManyRelations { edges { node { id name } } } + oneToManyRelations { edges { node { id name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $payload = $response->toArray(false); + if (isset($payload['errors'])) { + $this->fail('GraphQL errors: '.json_encode($payload['errors'], \JSON_PRETTY_PRINT)); + } + $d = $payload['data']['multiRelationsDummy']; + $this->assertSame('/multi_relations_dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertNotNull($d['manyToOneRelation']['id']); + $this->assertSame('RelatedManyToOneDummy #2', $d['manyToOneRelation']['name']); + $this->assertCount(2, $d['manyToManyRelations']['edges']); + $this->assertMatchesRegularExpression('#RelatedManyToManyDummy(1|2)2#', $d['manyToManyRelations']['edges'][0]['node']['name']); + $this->assertMatchesRegularExpression('#RelatedManyToManyDummy(1|2)2#', $d['manyToManyRelations']['edges'][1]['node']['name']); + $this->assertCount(3, $d['oneToManyRelations']['edges']); + $this->assertMatchesRegularExpression('#RelatedOneToManyDummy(1|3)2#', $d['oneToManyRelations']['edges'][0]['node']['name']); + $this->assertMatchesRegularExpression('#RelatedOneToManyDummy(1|3)2#', $d['oneToManyRelations']['edges'][2]['node']['name']); + } + + public function testQueryEmbeddedCollections(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 1, 2, 3, 4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneResolveRelation { id name } + nestedCollection { name } + nestedPaginatedCollection { edges { node { name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray(); + $this->assertArrayNotHasKey('errors', $d); + $dummy = $d['data']['multiRelationsDummy']; + $this->assertNotNull($dummy['manyToOneResolveRelation']['id']); + $this->assertSame('RelatedManyToOneResolveDummy #2', $dummy['manyToOneResolveRelation']['name']); + for ($i = 1; $i <= 4; ++$i) { + $this->assertSame('NestedDummy'.$i, $dummy['nestedCollection'][$i - 1]['name']); + } + // Edges count exists, but node.name resolves to null because JSON-column hydration + // returns associative arrays, not MultiRelationsNestedPaginated objects, so the + // GraphQL field resolver can't access ->name. Separate from the link bug. + $this->assertCount(4, $dummy['nestedPaginatedCollection']['edges']); + } + + public function testQueryWithUnsetRelations(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 0, 0, 0, 0); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id name + manyToOneRelation { id name } + manyToOneResolveRelation { id name } + manyToManyRelations { edges { node { id name } } } + oneToManyRelations { edges { node { id name } } } + nestedCollection { name } + nestedPaginatedCollection { edges { node { name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $d = $data['data']['multiRelationsDummy']; + $this->assertSame('/multi_relations_dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertNull($d['manyToOneRelation']); + $this->assertNull($d['manyToOneResolveRelation']); + $this->assertCount(0, $d['manyToManyRelations']['edges']); + $this->assertCount(0, $d['oneToManyRelations']['edges']); + $this->assertCount(0, $d['nestedCollection']); + $this->assertCount(0, $d['nestedPaginatedCollection']['edges']); + } + + public function testTreeDummiesChildRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('TreeDummy is ORM-only.'); + } + $this->recreateSchema([TreeDummy::class]); + $manager = $this->getManager(); + $parent = new TreeDummy(); + $child = new TreeDummy(); + $child->setParent($parent); + $manager->persist($parent); + $manager->persist($child); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + treeDummies { + edges { node { id children { totalCount } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $edges = $data['data']['treeDummies']['edges']; + $this->assertSame('/tree_dummies/1', $edges[0]['node']['id']); + $this->assertSame(1, $edges[0]['node']['children']['totalCount']); + $this->assertSame('/tree_dummies/2', $edges[1]['node']['id']); + $this->assertSame(0, $edges[1]['node']['children']['totalCount']); + } + + public function testRelayNode(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + node(id: "/dummies/1") { + id + ... on Dummy { name } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $node = $response->toArray()['data']['node']; + $this->assertSame('/dummies/1', $node['id']); + $this->assertSame('Dummy #1', $node['name']); + } + + public function testIterableField(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + $this->seedDummiesWithJsonAndArrayData(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/3") { + id + name + jsonData + arrayData + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('/dummies/3', $dummy['id']); + $this->assertSame('Dummy #1', $dummy['name']); + $this->assertCount(2, $dummy['jsonData']['foo']); + $this->assertSame(5, $dummy['jsonData']['bar']); + $this->assertSame('baz', $dummy['arrayData'][2]); + } + + public function testNullJsonField(): void + { + $this->recreateSchema([$this->isMongoDB() ? WithJsonDummyDocument::class : WithJsonDummy::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? WithJsonDummyDocument::class : WithJsonDummy::class; + for ($i = 1; $i <= 2; ++$i) { + $w = new $class(); + $w->json = null; + $manager->persist($w); + } + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + withJsonDummy(id: "/with_json_dummies/2") { + id + json + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $w = $response->toArray()['data']['withJsonDummy']; + $this->assertSame('/with_json_dummies/2', $w['id']); + $this->assertNull($w['json']); + } + + public function testQueryWithVariables(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl( + <<<'QUERY' + query DummyWithId($itemId: ID = "/dummies/1") { + dummyItem: dummy(id: $itemId) { + id + name + relatedDummy { id name } + } + } + QUERY, + ['itemId' => '/dummies/2'], + ); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['dummyItem']; + $this->assertSame('/dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertSame('/related_dummies/2', $d['relatedDummy']['id']); + $this->assertSame('RelatedDummy #2', $d['relatedDummy']['name']); + } + + public function testQueryWithOperationName(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $query = <<<'QUERY' + query DummyWithId1 { + dummyItem: dummy(id: "/dummies/1") { name } + } + query DummyWithId2 { + dummyItem: dummy(id: "/dummies/2") { id name } + } + QUERY; + + $response = $this->executeGraphQl($query, [], 'DummyWithId2'); + $d = $response->toArray()['data']['dummyItem']; + $this->assertSame('/dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + + $response = $this->executeGraphQl($query, [], 'DummyWithId1'); + $this->assertSame('Dummy #1', $response->toArray()['data']['dummyItem']['name']); + } + + public function testSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class]); + $this->seedDummyGroups(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyGroup(id: "/dummy_groups/1") { + foo + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('Foo #1', $response->toArray()['data']['dummyGroup']['foo']); + } + + public function testSerializedName(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class, + $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class, + ]); + $this->seedDummyCarWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyCar(id: "/dummy_cars/1") { + carBrand + } + } + QUERY); + + $this->assertSame('DummyBrand', $response->toArray()['data']['dummyCar']['carBrand']); + } + + public function testFetchOnlyInternalId(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + _id + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('1', (string) $response->toArray()['data']['dummy']['_id']); + } + + public function testNonexistentItemReturnsNull(): void + { + $this->recreateDummiesAndRelated(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/5") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['dummy']); + } + + public function testNonexistentIriYieldsDebugMessage(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + foo(id: "/foo/1") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertGraphQlDebugMessage($data, 'No route matches "/foo/1".'); + $this->assertCount(1, $data['errors']); + } + + public function testOutputClassUsedInsteadOfResource(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class]); + $this->seedDummyDtoNoInput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoNoInputs { + edges { node { baz bat } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'dummyDtoNoInputs' => [ + 'edges' => [ + ['node' => ['baz' => 0.33, 'bat' => 'DummyDtoNoInput foo #1']], + ['node' => ['baz' => 0.67, 'bat' => 'DummyDtoNoInput foo #2']], + ], + ], + ], + ], $response->toArray()); + } + + public function testDisableOutputClassYieldsEmptyResponse(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class, + $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class, + ]); + $this->seedDummyDtoNoOutput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoNoInputs { + edges { node { baz bat } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['dummyDtoNoInputs' => ['edges' => []]], + ], $response->toArray()); + } + + public function testCustomNotRetrievedItemQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testNotRetrievedItemDummyCustomQuery { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['testNotRetrievedItemDummyCustomQuery' => ['message' => 'Success (not retrieved)!']], + ], $response->toArray()); + } + + public function testCustomItemQueryWithReadAndSerializeDisabled(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testNoReadAndSerializeItemDummyCustomQuery(id: "/not_used") { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame(['data' => ['testNoReadAndSerializeItemDummyCustomQuery' => null]], $response->toArray()); + } + + public function testCustomItemQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testItemDummyCustomQuery(id: "/dummy_custom_queries/1") { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + ['data' => ['testItemDummyCustomQuery' => ['message' => 'Success!']]], + $response->toArray(), + ); + } + + public function testCustomItemQueryWithCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testItemCustomArgumentsDummyCustomQuery( + id: "/dummy_custom_queries/1", + customArgumentBool: true, + customArgumentInt: 3, + customArgumentString: "A string", + customArgumentFloat: 2.6, + customArgumentIntArray: [4], + customArgumentCustomType: "2019-05-24T00:00:00+00:00" + ) { + message + customArgs + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testItemCustomArgumentsDummyCustomQuery' => [ + 'message' => 'Success!', + 'customArgs' => [ + 'id' => '/dummy_custom_queries/1', + 'customArgumentBool' => true, + 'customArgumentInt' => 3, + 'customArgumentString' => 'A string', + 'customArgumentFloat' => 2.6, + 'customArgumentIntArray' => [4], + 'customArgumentCustomType' => '2019-05-24T00:00:00+00:00', + ], + ], + ], + ], $response->toArray()); + } + + public function testDifferentSerializationGroupsForItemAndCollection(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class; + $entity = new $class(); + $entity->setName('Name #1'); + $entity->setTitle('Title #1'); + $manager->persist($entity); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDifferentGraphQlSerializationGroup(id: "/dummy_different_graph_ql_serialization_groups/1") { + name + title + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['dummyDifferentGraphQlSerializationGroup']; + $this->assertSame('Name #1', $d['name']); + $this->assertSame('Title #1', $d['title']); + } + + public function testSecurityAfterResolver(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + getSecurityAfterResolver(id: "/security_after_resolvers/1") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test', $response->toArray()['data']['getSecurityAfterResolver']['name']); + } + + public function testSecurityAfterResolverDeniesNonMatchingId(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + getSecurityAfterResolver(id: "/security_after_resolvers/2") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertArrayNotHasKey('name', $data['data']['getSecurityAfterResolver'] ?? []); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + } + + private function recreateMultiRelations(): void + { + $this->recreateSchema([ + MultiRelationsDummy::class, + MultiRelationsRelatedDummy::class, + MultiRelationsResolveDummy::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithJsonAndArrayData(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setJsonData(['foo' => ['bar', 'baz'], 'bar' => 5]); + $dummy->setArrayData(['foo', 'bar', 'baz']); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedMultiRelations(int $nb, int $nbmtor, int $nbmtmr, int $nbotmr, int $nber): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $related = new MultiRelationsRelatedDummy(); + $related->name = 'RelatedManyToOneDummy #'.$i; + + $resolve = new MultiRelationsResolveDummy(); + $resolve->name = 'RelatedManyToOneResolveDummy #'.$i; + + $dummy = new MultiRelationsDummy(); + $dummy->name = 'Dummy #'.$i; + + if ($nbmtor) { + $dummy->setManyToOneRelation($related); + $dummy->setManyToOneResolveRelation($resolve); + } + + for ($j = 1; $j <= $nbmtmr; ++$j) { + $m2m = new MultiRelationsRelatedDummy(); + $m2m->name = 'RelatedManyToManyDummy'.$j.$i; + $manager->persist($m2m); + $dummy->addManyToManyRelation($m2m); + } + + for ($j = 1; $j <= $nbotmr; ++$j) { + $o2m = new MultiRelationsRelatedDummy(); + $o2m->name = 'RelatedOneToManyDummy'.$j.$i; + $o2m->setOneToManyRelation($dummy); + $manager->persist($o2m); + $dummy->addOneToManyRelation($o2m); + } + + $nested = new ArrayCollection(); + for ($j = 1; $j <= $nber; ++$j) { + $n = new MultiRelationsNested(); + $n->name = 'NestedDummy'.$j; + $nested->add($n); + } + $dummy->setNestedCollection($nested); + + $nestedPaginated = new ArrayCollection(); + for ($j = 1; $j <= $nber; ++$j) { + $np = new MultiRelationsNestedPaginated(); + $np->name = 'NestedPaginatedDummy'.$j; + $nestedPaginated->add($np); + } + $dummy->setNestedPaginatedCollection($nestedPaginated); + + $manager->persist($related); + $manager->persist($resolve); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummyGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $group = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $property) { + $group->{$property} = ucfirst($property).' #'.$i; + } + $manager->persist($group); + } + $manager->flush(); + } + + private function seedDummyCarWithColors(): void + { + $manager = $this->getManager(); + $carClass = $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class; + $colorClass = $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class; + + $car = new $carClass(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + if (\is_object($car->getId())) { + $manager->persist($car->getId()); + $manager->flush(); + } + $red = new $colorClass(); + $red->setProp('red'); + $red->setCar($car); + $manager->persist($red); + $blue = new $colorClass(); + $blue->setProp('blue'); + $blue->setCar($car); + $manager->persist($blue); + $manager->flush(); + } + + private function seedDummyDtoNoInput(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class; + for ($i = 1; $i <= $count; ++$i) { + $dto = new $class(); + $dto->lorem = 'DummyDtoNoInput foo #'.$i; + $dto->ipsum = round($i / 3, 2); + $manager->persist($dto); + } + $manager->flush(); + } + + private function seedDummyDtoNoOutput(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class; + for ($i = 1; $i <= $count; ++$i) { + $dto = new $class(); + $dto->lorem = 'DummyDtoNoOutput foo #'.$i; + $dto->ipsum = (string) round($i / 3, 2); + $manager->persist($dto); + } + $manager->flush(); + } + + private function seedDummyCustomQuery(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class; + for ($i = 1; $i <= $count; ++$i) { + $manager->persist(new $class()); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/SchemaExportTest.php b/tests/Functional/GraphQl/SchemaExportTest.php new file mode 100644 index 00000000000..06360f1212c --- /dev/null +++ b/tests/Functional/GraphQl/SchemaExportTest.php @@ -0,0 +1,174 @@ + + * + * 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\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OptionalRequiredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Tester\ApplicationTester; + +final class SchemaExportTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private ApplicationTester $tester; + + protected function setUp(): void + { + self::bootKernel(); + + $application = new Application(static::$kernel); + $application->setCatchExceptions(false); + $application->setAutoExit(false); + $this->tester = new ApplicationTester($application); + } + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + DummyFriend::class, + RelatedToDummyFriend::class, + OptionalRequiredDummy::class, + ThirdLevel::class, + ]; + } + + public function testExportGraphQlSchema(): void + { + $this->tester->run(['command' => 'api:graphql:export']); + + $output = $this->tester->getDisplay(); + + $this->assertStringContainsString(<<<'SDL' + "Dummy Friend." + type DummyFriend implements Node { + id: ID! + + "The id" + _id: Int! + + "The dummy name" + name: String! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Cursor connection for DummyFriend." + type DummyFriendCursorConnection { + edges: [DummyFriendEdge] + pageInfo: DummyFriendPageInfo! + totalCount: Int! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Edge of DummyFriend." + type DummyFriendEdge { + node: DummyFriend + cursor: String! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Information about the current page." + type DummyFriendPageInfo { + endCursor: String + startCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + updateDummyFriend(input: updateDummyFriendInput!): updateDummyFriendPayload + + "Deletes a DummyFriend." + deleteDummyFriend(input: deleteDummyFriendInput!): deleteDummyFriendPayload + + "Creates a DummyFriend." + createDummyFriend(input: createDummyFriendInput!): createDummyFriendPayload + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + input updateDummyFriendInput { + id: ID! + + "The dummy name" + name: String + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + type updateDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Deletes a DummyFriend." + input deleteDummyFriendInput { + id: ID! + clientMutationId: String + } + + "Deletes a DummyFriend." + type deleteDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Creates a DummyFriend." + input createDummyFriendInput { + "The dummy name" + name: String! + clientMutationId: String + } + + "Creates a DummyFriend." + type createDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a OptionalRequiredDummy." + input updateOptionalRequiredDummyInput { + id: ID! + thirdLevel: updateThirdLevelNestedInput + thirdLevelRequired: updateThirdLevelNestedInput! + + "Get relatedToDummyFriend." + relatedToDummyFriend: [updateRelatedToDummyFriendNestedInput] + clientMutationId: String + } + SDL, $output); + } +} diff --git a/tests/Functional/GraphQl/SubscriptionTest.php b/tests/Functional/GraphQl/SubscriptionTest.php new file mode 100644 index 00000000000..d653f7d6bc3 --- /dev/null +++ b/tests/Functional/GraphQl/SubscriptionTest.php @@ -0,0 +1,254 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Mercure\TestHub; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SubscriptionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyMercure::class, RelatedDummy::class]; + } + + public function testIntrospectSubscriptionType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Subscription") { + fields { + name + description + type { name kind } + args { + name + type { name kind ofType { name kind } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $fields = $response->toArray()['data']['__type']['fields']; + $this->assertNotEmpty($fields); + + foreach ($fields as $field) { + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+Subscribe$/', $field['name']); + $this->assertMatchesRegularExpression('/^Subscribes to the update event of a [A-Za-z0-9_]+\.$/', $field['description']); + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+SubscriptionPayload$/', $field['type']['name']); + $this->assertSame('OBJECT', $field['type']['kind']); + + $this->assertCount(1, $field['args']); + $arg = $field['args'][0]; + $this->assertSame('input', $arg['name']); + $this->assertSame('NON_NULL', $arg['type']['kind']); + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+SubscriptionInput$/', $arg['type']['ofType']['name']); + $this->assertSame('INPUT_OBJECT', $arg['type']['ofType']['kind']); + } + } + + public function testSubscribeToUpdatesProducesMercureUrl(): void + { + $this->recreateSchema($this->resources()); + $this->seedDummyMercure(2); + + $response = $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { + dummyMercure { + id + name + relatedDummy { + name + } + } + mercureUrl + clientSubscriptionId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['updateDummyMercureSubscribe']; + $this->assertSame('/dummy_mercures/1', $data['dummyMercure']['id']); + $this->assertSame('Dummy Mercure #1', $data['dummyMercure']['name']); + $this->assertSame('myId', $data['clientSubscriptionId']); + $this->assertMatchesRegularExpression( + '@^https://demo\.mercure\.rocks\?topic=http://[^/]+/subscriptions/[a-f0-9]+$@', + $data['mercureUrl'], + ); + + $response = $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { + dummyMercure { id } + mercureUrl + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['updateDummyMercureSubscribe']; + $this->assertSame('/dummy_mercures/2', $data['dummyMercure']['id']); + $this->assertMatchesRegularExpression( + '@^https://demo\.mercure\.rocks\?topic=http://[^/]+/subscriptions/[a-f0-9]+$@', + $data['mercureUrl'], + ); + } + + public function testReceiveMercureUpdatesAfterPut(): void + { + $this->recreateSchema($this->resources()); + $this->seedDummyMercure(2); + + $client = self::createClient(); + $client->getKernelBrowser()->disableReboot(); + + // Subscribe to both dummies so the SubscriptionManager registers different payload shapes. + $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { + dummyMercure { id name relatedDummy { name } } + mercureUrl + } + } + QUERY); + $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { + dummyMercure { id } + mercureUrl + } + } + QUERY); + + $client->request('PUT', '/dummy_mercures/1', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Dummy Mercure #1 updated'], + ]); + $this->assertResponseIsSuccessful(); + + $client->request('PUT', '/dummy_mercures/2', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Dummy Mercure #2 updated'], + ]); + $this->assertResponseIsSuccessful(); + + /** @var TestHub $hub */ + $hub = static::getContainer()->get('mercure.hub.default.test_hub'); + $updates = $hub->getUpdates(); + + $this->assertGreaterThanOrEqual(2, \count($updates)); + + $this->assertMercureUpdatePresent($updates, '#^http://[^/]+/subscriptions/[a-f0-9]+$#', [ + 'dummyMercure' => [ + 'id' => 1, + 'name' => 'Dummy Mercure #1 updated', + 'relatedDummy' => ['name' => 'RelatedDummy #1'], + ], + ]); + + $this->assertMercureUpdatePresent($updates, '#^http://[^/]+/subscriptions/[a-f0-9]+$#', [ + 'dummyMercure' => ['id' => 2], + ]); + } + + /** + * @param list<\Symfony\Component\Mercure\Update> $updates + * @param array $expectedPayload + */ + private function assertMercureUpdatePresent(array $updates, string $topicPattern, array $expectedPayload): void + { + $expectedJson = json_encode($expectedPayload, \JSON_THROW_ON_ERROR); + + foreach ($updates as $update) { + $topicsMatch = false; + foreach ($update->getTopics() as $topic) { + if (preg_match($topicPattern, (string) $topic)) { + $topicsMatch = true; + break; + } + } + if (!$topicsMatch) { + continue; + } + + if ($update->getData() === $expectedJson) { + $this->assertTrue(true); + + return; + } + } + + $this->fail(\sprintf( + 'No Mercure update matched topic %s with payload %s. Captured: %s', + $topicPattern, + $expectedJson, + json_encode(array_map( + static fn ($u) => ['topics' => $u->getTopics(), 'data' => $u->getData()], + $updates, + ), \JSON_PRETTY_PRINT), + )); + } + + /** + * @return list + */ + private function resources(): array + { + return [ + $this->isMongoDB() ? DummyMercureDocument::class : DummyMercure::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]; + } + + private function seedDummyMercure(int $count): void + { + $manager = $this->getManager(); + $relatedClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $dummyClass = $this->isMongoDB() ? DummyMercureDocument::class : DummyMercure::class; + + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->setName('RelatedDummy #'.$i); + + $dummy = new $dummyClass(); + $dummy->name = "Dummy Mercure #$i"; + $dummy->description = 'Description'; + $dummy->relatedDummy = $related; + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/SetupClassResourcesTrait.php b/tests/SetupClassResourcesTrait.php index 32f08efc2f9..16b61907db7 100644 --- a/tests/SetupClassResourcesTrait.php +++ b/tests/SetupClassResourcesTrait.php @@ -26,6 +26,7 @@ public static function setUpBeforeClass(): void public static function tearDownAfterClass(): void { + static::ensureKernelShutdown(); static::removeResources(); $reflectionClass = new \ReflectionClass(Router::class); $reflectionClass->setStaticPropertyValue('cache', []); diff --git a/tests/WithResourcesTrait.php b/tests/WithResourcesTrait.php index 464c653c263..27394596115 100644 --- a/tests/WithResourcesTrait.php +++ b/tests/WithResourcesTrait.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; + trait WithResourcesTrait { /** @@ -21,10 +23,53 @@ trait WithResourcesTrait protected static function writeResources(array $resources): void { file_put_contents(__DIR__.'/Fixtures/app/var/resources.php', \sprintf(' $v.'::class', $resources)))); + static::invalidateMetadataPools(); } protected static function removeResources(): void { file_put_contents(__DIR__.'/Fixtures/app/var/resources.php', 'hasProperty('valuesCache')) { + $property = $reflection->getProperty('valuesCache'); + $property->setValue(null, []); + } + } + + private static function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + + @rmdir($dir); } } From ee103b06ed7fa684f2e7e601bcf6200591630bdb Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 09:49:23 +0200 Subject: [PATCH 04/13] chore: remove behat test infrastructure Behat features fully migrated to PHPUnit functional tests (tests/Functional/**). Strip remaining behat scaffolding. - composer.json: drop behat/behat, behat/mink, friends-of-behat/*, soyuka/contexts dev deps - .github/workflows/ci.yml: remove behat, behat-symfony-next, windows-behat, behat-symfony-lowest, behat_listeners jobs; rename postgresql/mysql/mongodb/mercure (PHPUnit + Behat -> PHPUnit) and drop behat steps; switch mongodb/mercure coverage uploads to phpunit - delete tests/Behat/*, behat.yml.dist, config_behat_{orm,mongodb}.yml - move features/files/test.gif to tests/Functional/GraphQl/Fixtures/ - AppKernel: drop FriendsOfBehatSymfonyExtensionBundle, DoctrineContext loader, stale behat compat comment - CONTRIBUTING.md, AGENTS.md, tests/AGENTS.md, .phpactor.json, phpunit.xml.dist: remove behat references --- .github/workflows/ci.yml | 342 +-- AGENTS.md | 9 +- CONTRIBUTING.md | 21 +- behat.yml.dist | 210 -- composer.json | 6 - phpunit.xml.dist | 2 - src/Doctrine/Odm/Tests/AppKernel.php | 1 - src/Doctrine/Orm/Tests/AppKernel.php | 1 - tests/AGENTS.md | 6 - tests/Behat/CommandContext.php | 106 - tests/Behat/CoverageContext.php | 92 - tests/Behat/DoctrineContext.php | 2707 ----------------- tests/Behat/HttpCacheContext.php | 91 - tests/Behat/HydraContext.php | 326 -- tests/Behat/JsonApiContext.php | 209 -- tests/Behat/JsonContext.php | 112 - tests/Behat/JsonHalContext.php | 80 - tests/Behat/MercureContext.php | 144 - tests/Behat/XmlContext.php | 43 - tests/Fixtures/app/AppKernel.php | 12 - .../app/config/config_behat_mongodb.yml | 17 - .../Fixtures/app/config/config_behat_orm.yml | 18 - .../Functional/GraphQl/Fixtures}/test.gif | Bin tests/Functional/GraphQl/MutationTest.php | 2 +- 24 files changed, 26 insertions(+), 4531 deletions(-) delete mode 100644 behat.yml.dist delete mode 100644 tests/Behat/CommandContext.php delete mode 100644 tests/Behat/CoverageContext.php delete mode 100644 tests/Behat/DoctrineContext.php delete mode 100644 tests/Behat/HttpCacheContext.php delete mode 100644 tests/Behat/HydraContext.php delete mode 100644 tests/Behat/JsonApiContext.php delete mode 100644 tests/Behat/JsonContext.php delete mode 100644 tests/Behat/JsonHalContext.php delete mode 100644 tests/Behat/MercureContext.php delete mode 100644 tests/Behat/XmlContext.php delete mode 100644 tests/Fixtures/app/config/config_behat_mongodb.yml delete mode 100644 tests/Fixtures/app/config/config_behat_orm.yml rename {features/files => tests/Functional/GraphQl/Fixtures}/test.gif (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 021767ec54b..62d9ce3f8ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -447,94 +447,8 @@ jobs: cd $(composer ${{matrix.component}} --cwd) ./vendor/bin/phpunit --fail-on-deprecation --display-deprecations --log-junit "/tmp/build/logs/phpunit/junit.xml" - behat: - name: Behat (PHP ${{ matrix.php }} ${{ matrix.shard }}) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} - shard: - - graphql-doctrine - include: - - php: '8.5' - shard: graphql-doctrine - coverage: true - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite - coverage: pcov - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Resolve shard paths - id: shard - run: | - case "${{ matrix.shard }}" in - graphql-doctrine) paths="features/graphql features/doctrine" ;; - esac - echo "paths=$paths" >> $GITHUB_OUTPUT - - name: Run Behat tests (PHP ${{ matrix.php }} ${{ matrix.shard }}) - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --no-interaction ${{ matrix.coverage && '--profile=default-coverage' || '--profile=default' }} ${{ steps.shard.outputs.paths }} - - name: Merge code coverage reports - if: matrix.coverage - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v6 - with: - name: behat-logs-php${{ matrix.php }}-shard${{ matrix.shard }} - path: build/logs/behat - continue-on-error: true - - name: Upload coverage results to Codecov - if: matrix.coverage - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }}-shard${{ matrix.shard }} - flags: behat - fail_ci_if_error: true - continue-on-error: true - - name: Upload coverage results to Coveralls - if: matrix.coverage - env: - COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls - export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml - continue-on-error: true - postgresql: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (PostgreSQL) + name: PHPUnit (PHP ${{ matrix.php }}) (PostgreSQL) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -581,14 +495,9 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: | - vendor/bin/behat --out=std --format=progress --profile=postgres --no-interaction -vv mysql: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (MySQL) + name: PHPUnit (PHP ${{ matrix.php }}) (MySQL) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -636,13 +545,9 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags '~@!mysql' mongodb: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (MongoDB) + name: PHPUnit (PHP ${{ matrix.php }}) (MongoDB) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -692,33 +597,20 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit --log-junit build/logs/phpunit/junit.xml --coverage-clover build/logs/phpunit/clover.xml --exclude-group=orm - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb-coverage --no-interaction - - name: Merge code coverage reports - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - continue-on-error: true - name: Upload test artifacts if: always() uses: actions/upload-artifact@v6 with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat + name: phpunit-logs-php${{ matrix.php }}-mongodb + path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }} - flags: behat + directory: build/logs/phpunit + name: phpunit-php${{ matrix.php }}-mongodb + flags: phpunit fail_ci_if_error: true continue-on-error: true - name: Upload coverage results to Coveralls @@ -727,11 +619,11 @@ jobs: run: | composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml + php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true mercure: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (Mercure) + name: PHPUnit (PHP ${{ matrix.php }}) (Mercure) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -785,31 +677,20 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit --log-junit build/logs/phpunit/junit.xml --coverage-clover build/logs/phpunit/clover.xml --group mercure - - name: Run Behat tests - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mercure-coverage --no-interaction - - name: Merge code coverage reports - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - continue-on-error: true - name: Upload test artifacts if: always() uses: actions/upload-artifact@v6 with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat + name: phpunit-logs-php${{ matrix.php }}-mercure + path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }} - flags: behat + directory: build/logs/phpunit + name: phpunit-php${{ matrix.php }}-mercure + flags: phpunit fail_ci_if_error: true continue-on-error: true - name: Upload coverage results to Coveralls @@ -818,7 +699,7 @@ jobs: run: | composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml + php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true elasticsearch: @@ -1029,50 +910,6 @@ jobs: - name: Run PHPUnit tests run: vendor/bin/phpunit --fail-on-deprecation - behat-symfony-next: - name: Behat (PHP ${{ matrix.php }}) (Symfony dev) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Allow unstable project dependencies - run: composer config minimum-stability dev - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Remove cache - run: rm -Rf tests/Fixtures/app/var/cache/* - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction - - # remove once behat can be installed with symfony 8.1 phpunit-symfony-edge: name: PHPUnit (PHP ${{ matrix.php }}) (Symfony 8.1) runs-on: ubuntu-latest @@ -1099,8 +936,6 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Allow unstable project dependencies run: composer config minimum-stability dev - - name: Drop Behat dev dependencies (incompatible with Symfony 8.1) - run: composer remove --no-update --no-interaction --dev behat/behat behat/mink soyuka/contexts friends-of-behat/symfony-extension friends-of-behat/mink-browserkit-driver friends-of-behat/mink-extension - name: Force Symfony 8.1 dev for framework-bundle and json-streamer run: composer require --dev --no-update --no-interaction "symfony/framework-bundle:8.1.x-dev" "symfony/json-streamer:8.1.x-dev" - name: Cache dependencies @@ -1121,59 +956,6 @@ jobs: - name: Run PHPUnit tests run: vendor/bin/phpunit - windows-behat: - name: Windows Behat (PHP ${{ matrix.php }}) (SQLite) - runs-on: windows-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - env: - APP_ENV: sqlite - DATABASE_URL: sqlite:///%kernel.project_dir%/var/data.db - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP with pre-release PECL extension - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, fileinfo - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - shell: bash - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Keep windows path - id: get-cwd - shell: bash - run: | - cwd=$(php -r 'echo(str_replace("\\", "\\\\", $_SERVER["argv"][1]));' '${{ github.workspace }}') - echo cwd=$cwd >> $GITHUB_OUTPUT - - name: Update project dependencies - shell: bash - run: | - php -m - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . --working-directory='${{ steps.get-cwd.outputs.cwd }}' - - name: Clear test app cache - shell: bash - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - shell: bash - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction - phpunit-symfony-lowest: name: PHPUnit (PHP ${{ matrix.php }}) (Symfony lowest) runs-on: ubuntu-latest @@ -1218,48 +1000,6 @@ jobs: env: SYMFONY_DEPRECATIONS_HELPER: max[self]=0&ignoreFile=./tests/.ignored-deprecations - behat-symfony-lowest: - name: Behat (PHP ${{ matrix.php }}) (Symfony lowest) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Remove cache - run: rm -Rf tests/Fixtures/app/var/cache/* - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . --permanent - composer update --prefer-lowest - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags='~@disableForSymfonyLowest' - phpunit_listeners: name: PHPUnit event listeners (PHP ${{ matrix.php }}) env: @@ -1339,56 +1079,6 @@ jobs: php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true - behat_listeners: - name: Behat event listeners (PHP ${{ matrix.php }}) - env: - USE_SYMFONY_LISTENERS: 1 - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite - coverage: pcov - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests (PHP 8) - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=symfony_listeners --no-interaction - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v6 - with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat - continue-on-error: true - openapi: name: OpenAPI runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 4f0d29e0b27..8f0adddc594 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ You are an expert Core Contributor to API Platform, a PHP framework supporting S * Context Retrieval (VectorCode): Before writing new code or asking for clarification, ALWAYS use vectorcode if available to search for existing patterns, interfaces, or similar implementations in the codebase. * Test-First Mandate: Your primary output should be functional tests to expose bugs or verify features. Do not fix bugs unless explicitly requested. -* Execution Restraint: NEVER run the full test suite (Behat or PHPUnit). It is too slow. Only run specific, filtered tests relevant to the current task. +* Execution Restraint: NEVER run the full PHPUnit test suite. It is too slow. Only run specific, filtered tests relevant to the current task. * Fixture Isolation: Do not modify existing fixtures (tests/Fixtures/...). Always create new Entities, DTOs, or Models to prevent regression in other tests. * Git Policy: Do not perform git commits unless explicitly asked. @@ -26,7 +26,7 @@ When to use: 3. Testing Quick-Reference (Default/Symfony) -For advanced configurations (Event Listeners, MongoDB, Behat tuning), refer to `tests/AGENTS.md`. +For advanced configurations (Event Listeners, MongoDB), refer to `tests/AGENTS.md`. Common Commands: @@ -43,12 +43,9 @@ rm -rf tests/Fixtures/app/var/cache/test # indefinitely. Remove them before running tests: find src -name vendor -exec rm -rf {} + -# PHPUnit (Preferred) +# PHPUnit vendor/bin/phpunit --filter testMethodName -# Behat (Legacy) -vendor/bin/behat features/main/crud.feature:120 --format=progress - #Component Testing cd src/Metadata composer link ../../ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89ba9542382..5c18c931cfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ See also the [related documentation for Symfony](https://symfony.com/doc/current When you send a PR, just make sure that: -* You add valid test cases (Behat and PHPUnit). +* You add valid test cases (PHPUnit). * Tests are green. * You make a PR on the related documentation in the [api-platform/docs](https://github.com/api-platform/docs) repository. * You make the PR on the same branch you based your changes on. If you see commits @@ -123,11 +123,11 @@ Only the first commit on a Pull Request need to use a conventional commit, other ### Tests -On `api-platform/core` there are two kinds of tests: unit (`phpunit`) and integration tests (`behat`). +On `api-platform/core` tests are written with `phpunit` (unit tests and functional tests under `tests/Functional`). Note that we stopped using `prophesize` for new tests since 3.2, use `phpunit` stub system. -Both `phpunit` and `behat` are development dependencies and should be available in the `vendor` directory. +`phpunit` is a development dependency and should be available in the `vendor` directory. Recommendations: @@ -157,20 +157,11 @@ Sometimes there might be an error with too many open files when generating cover Coverage will be available in `coverage/index.html`. -#### Behat +To run functional tests for MongoDB: -> [!WARNING] -> Please **do not add new Behat tests**, use a functional test (for example: [ComputedFieldTest](https://github.com/api-platform/core/blob/04d5cff1b28b494ac2e90257a79ce6c045ba82ae/tests/Functional/Doctrine/ComputedFieldTest.php)). + MONGODB_URL=mongodb://localhost:27017 APP_ENV=mongodb vendor/bin/phpunit --group mongodb -The command to launch Behat tests is: - - php -d memory_limit=-1 ./vendor/bin/behat --profile=default --stop-on-failure --format=progress - -If you want to launch Behat tests for MongoDB, the command is: - - MONGODB_URL=mongodb://localhost:27017 APP_ENV=mongodb php -d memory_limit=-1 ./vendor/bin/behat --profile=mongodb --stop-on-failure --format=progress - -To get more details about an error, replace `--format=progress` by `-vvv`. You may run a mongo instance using docker: +You may run a mongo instance using docker: docker run -p 27017:27017 mongo:latest diff --git a/behat.yml.dist b/behat.yml.dist deleted file mode 100644 index fb84483e88d..00000000000 --- a/behat.yml.dist +++ /dev/null @@ -1,210 +0,0 @@ -default: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@controller&&~@mercure&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ - -postgres: - suites: - default: false - postgres: &postgres-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@sqlite&&~@mongodb&&~@elasticsearch&&~@controller&&~@mercure&&~@query_parameter_validator' - -mongodb: - suites: - default: false - mongodb: &mongodb-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@sqlite&&~@elasticsearch&&~@!mongodb&&~@mercure&&~@controller&&~@query_parameter_validator' - -mercure: - suites: - default: false - mercure: &mercure-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '@mercure' - -default-coverage: - suites: - default: &default-coverage-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -mongodb-coverage: - suites: - default: false - mongodb: &mongodb-coverage-suite - <<: *mongodb-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -mercure-coverage: - suites: - default: false - mongodb: &mercure-coverage-suite - <<: *mercure-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -legacy: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@link_security&&~@use_listener&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ - -symfony_listeners: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@mercure&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ diff --git a/composer.json b/composer.json index 7ffc3c950ee..89a068313e3 100644 --- a/composer.json +++ b/composer.json @@ -125,16 +125,11 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "behat/behat": "^3.11", - "behat/mink": "^1.9", "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", - "friends-of-behat/mink-browserkit-driver": "^1.3.1", - "friends-of-behat/mink-extension": "^2.2", - "friends-of-behat/symfony-extension": "^2.1", "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", "illuminate/config": "^11.0 || ^12.0 || ^13.0", @@ -160,7 +155,6 @@ "psr/log": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "soyuka/contexts": "^3.3.10", "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", "symfony/asset": "^6.4 || ^7.0 || ^8.0", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9f177ef37f1..aa1d500db50 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,7 +12,6 @@ - @@ -40,7 +39,6 @@ tests - features vendor .php-cs-fixer.dist.php diff --git a/src/Doctrine/Odm/Tests/AppKernel.php b/src/Doctrine/Odm/Tests/AppKernel.php index 773a4e31592..039813186e5 100644 --- a/src/Doctrine/Odm/Tests/AppKernel.php +++ b/src/Doctrine/Odm/Tests/AppKernel.php @@ -33,7 +33,6 @@ public function __construct(string $environment, bool $debug) { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; } diff --git a/src/Doctrine/Orm/Tests/AppKernel.php b/src/Doctrine/Orm/Tests/AppKernel.php index 66c5948a28c..3abb43d2880 100644 --- a/src/Doctrine/Orm/Tests/AppKernel.php +++ b/src/Doctrine/Orm/Tests/AppKernel.php @@ -33,7 +33,6 @@ public function __construct(string $environment, bool $debug) { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; } diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 89440051446..5b27935eed7 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -24,12 +24,6 @@ To run tests against MongoDB: ## Execution Guidelines -### Behat (Functional) - -* **Progress Format:** ALWAYS use \--format=progress. Without this, output verbosity increases execution time from \~10m to \~30m. -* **Tags:** Filter efficiently: vendor/bin/behat \--tags=@pagination \--format=progress -* **Debugging:** Only drop \--format=progress if you need to debug a *single* scenario using \-vvv. - ### PHPUnit * **Filtering:** Never run the full suite. Always filter by class or path. diff --git a/tests/Behat/CommandContext.php b/tests/Behat/CommandContext.php deleted file mode 100644 index 666f387410a..00000000000 --- a/tests/Behat/CommandContext.php +++ /dev/null @@ -1,106 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use GraphQL\Error\Error; -use PHPUnit\Framework\Assert; -use Symfony\Bundle\FrameworkBundle\Console\Application; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\HttpKernel\KernelInterface; - -/** - * Context for Symfony commands. - * - * @author Alan Poulain - */ -final class CommandContext implements Context -{ - private ?Application $application = null; - - private ?CommandTester $commandTester = null; - - public function __construct(private KernelInterface $kernel) - { - } - - /** - * @When I run the command :command - */ - public function iRunTheCommand(string $command): void - { - $command = $this->getApplication()->find($command); - - $this->getCommandTester($command)->execute([]); - } - - /** - * @When I run the command :command with options: - */ - public function iRunTheCommandWithOptions(string $command, TableNode $options): void - { - $command = $this->getApplication()->find($command); - - $this->getCommandTester($command)->execute($options->getRowsHash()); - } - - /** - * @Then the command output should be: - */ - public function theCommandOutputShouldBe(PyStringNode $expectedOutput): void - { - Assert::assertEquals($expectedOutput->getRaw(), $this->commandTester->getDisplay()); - } - - /** - * @Then the command output should contain: - */ - public function theCommandOutputShouldContain(PyStringNode $expectedOutput): void - { - // graphql-php < 15 - if (\defined(Error::class.'::CATEGORY_GRAPHQL')) { - $expectedOutput = str_replace('###', '"""', $expectedOutput->getRaw()); - } else { - $expectedOutput = str_replace('###', '"', $expectedOutput->getRaw()); - } - - Assert::assertStringContainsString($expectedOutput, $this->commandTester->getDisplay()); - } - - public function setKernel(KernelInterface $kernel): void - { - $this->kernel = $kernel; - } - - public function getApplication(): Application - { - if (null !== $this->application) { - return $this->application; - } - - $this->application = new Application($this->kernel); - - return $this->application; - } - - private function getCommandTester(Command $command): CommandTester - { - $this->commandTester = new CommandTester($command); - - return $this->commandTester; - } -} diff --git a/tests/Behat/CoverageContext.php b/tests/Behat/CoverageContext.php deleted file mode 100644 index ee5c171cd3d..00000000000 --- a/tests/Behat/CoverageContext.php +++ /dev/null @@ -1,92 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Driver\Selector; -use SebastianBergmann\CodeCoverage\Filter; -use SebastianBergmann\CodeCoverage\Report\PHP; -use Symfony\Component\Finder\Finder; - -/** - * Behat coverage. - * - * @author eliecharra - * @author Kévin Dunglas - * @copyright Adapted from https://gist.github.com/eliecharra/9c8b3ba57998b50e14a6 - */ -final class CoverageContext implements Context -{ - /** - * @var CodeCoverage - */ - private static $coverage; - - /** - * @BeforeSuite - */ - public static function setup(): void - { - $filter = new Filter(); - $finder = - (new Finder()) - ->in(__DIR__.'/../../src') - ->exclude([ - 'src/Core/Bridge/Symfony/Maker/Resources/skeleton', - 'tests/Fixtures/app/var', - 'docs/guides', - 'docs/var', - 'src/Doctrine/Orm/Tests/var', - 'src/Doctrine/Odm/Tests/var', - ]) - ->append([ - 'tests/Fixtures/app/console', - ]) - ->files() - ->name('*.php'); - - foreach ($finder as $file) { - $filter->includeFile((string) $file); - } - - self::$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); - } - - /** - * @AfterSuite - */ - public static function teardown(): void - { - $feature = getenv('FEATURE') ?: 'behat'; - (new PHP())->process(self::$coverage, __DIR__."/../../build/coverage/coverage-$feature.cov"); - } - - /** - * @BeforeScenario - */ - public function before(BeforeScenarioScope $scope): void - { - self::$coverage->start("{$scope->getFeature()->getTitle()}::{$scope->getScenario()->getTitle()}"); - } - - /** - * @AfterScenario - */ - public function after(): void - { - self::$coverage->stop(); - } -} diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php deleted file mode 100644 index 4639644beb4..00000000000 --- a/tests/Behat/DoctrineContext.php +++ /dev/null @@ -1,2707 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\AbsoluteUrlDummy as AbsoluteUrlDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\AbsoluteUrlRelationDummy as AbsoluteUrlRelationDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Address as AddressDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Answer as AnswerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Book as BookDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Comment as CommentDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeItem as CompositeItemDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeLabel as CompositeLabelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositePrimitiveItem as CompositePrimitiveItemDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeRelation as CompositeRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedBoolean as ConvertedBoolDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedDate as ConvertedDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedString as ConvertedStringDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Customer as CustomerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CustomMultipleIdentifierDummy as CustomMultipleIdentifierDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAggregateOffer as DummyAggregateOfferDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomMutation as DummyCustomMutationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoCustom as DummyDtoCustomDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyImmutableDate as DummyImmutableDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyOffer as DummyOfferDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyPassenger as DummyPassengerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyProduct as DummyProductDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyProperty as DummyPropertyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyTableInheritanceNotApiResourceChild as DummyTableInheritanceNotApiResourceChildDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyTravel as DummyTravelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FileConfigDummy as FileConfigDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooEmbeddable as FooEmbeddableDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriOnlyDummy as IriOnlyDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\LinkHandledDummy as LinkHandledDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNested as MultiRelationsNestedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNestedPaginated as MultiRelationsNestedPaginatedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsResolveDummy as MultiRelationsResolveDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PatchDummyRelation as PatchDummyRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Payment as PaymentDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PersonToPet as PersonToPetDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Pet as PetDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Product as ProductDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Program as ProgramDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnly as PropertyCollectionIriOnlyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnlyRelation as PropertyCollectionIriOnlyRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyUriTemplateOneToOneRelation as PropertyUriTemplateOneToOneRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Question as QuestionDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedLinkedDummy as RelatedLinkedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwnedDummy as RelatedOwnedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwningDummy as RelatedOwningDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedSecuredDummy as RelatedSecuredDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedToDummyFriend as RelatedToDummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelationEmbedder as RelationEmbedderDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as SecuredDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SeparatedEntity as SeparatedEntityDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Taxon as TaxonDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\UrlEncodedId as UrlEncodedIdDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\User as UserDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\VideoGame as VideoGameDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\WithJsonDummy as WithJsonDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlRelationDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Address; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Answer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Book; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Comment; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedString; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Customer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomMultipleIdentifierDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomMutation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMappedSubclass; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyOffer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPassenger; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EntityClassWithDateTime; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ExternalUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooEmbeddable; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Greeting; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InitializeInput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InternalUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriOnlyDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\Event; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\ItemLog; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Group; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6039\Issue6039EntityUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LinkHandledDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNested; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNestedPaginated; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsResolveDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Order; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PaginationEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PatchDummyRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Payment; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PersonToPet; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Pet; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Product; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Program; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedSecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationMultiple; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SeparatedEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Site; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TreeDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UrlEncodedId; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WithJsonDummy; -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ODM\MongoDB\DocumentManager; -use Doctrine\ODM\MongoDB\Query\Builder; -use Doctrine\ODM\MongoDB\SchemaManager; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Tools\SchemaTool; -use Doctrine\Persistence\ManagerRegistry; -use Doctrine\Persistence\ObjectManager; -use Ramsey\Uuid\Uuid; -use Symfony\Component\Uid\Uuid as SymfonyUuid; - -/** - * Defines application features from the specific context. - */ -final class DoctrineContext implements Context -{ - private ObjectManager $manager; - private ?SchemaTool $schemaTool; - private ?SchemaManager $schemaManager; - - /** - * Initializes context. - * - * Every scenario gets its own context instance. - * You can also pass arbitrary arguments to the - * context constructor through behat.yml. - */ - public function __construct(private readonly ManagerRegistry $doctrine, private readonly mixed $passwordHasher) - { - $this->manager = $doctrine->getManager(); - $this->schemaTool = $this->manager instanceof EntityManagerInterface ? new SchemaTool($this->manager) : null; - $this->schemaManager = $this->manager instanceof DocumentManager ? $this->manager->getSchemaManager() : null; - } - - /** - * @BeforeScenario @createSchema - */ - public function createDatabase(): void - { - /** @var ClassMetadata[] $classes */ - $classes = $this->manager->getMetadataFactory()->getAllMetadata(); - - if ($this->isOrm()) { - $this->schemaTool->dropSchema($classes); - $this->schemaTool->createSchema($classes); - } - - if ($this->isOdm()) { - $this->schemaManager->dropDatabases(); - } - - $this->doctrine->getManager()->clear(); - } - - /** - * @Then the DQL should be equal to: - */ - public function theDqlShouldBeEqualTo(PyStringNode $dql): void - { - /** @var EntityManager $manager */ - $manager = $this->doctrine->getManager(); - - $actualDql = $manager::$dql; - - $expectedDql = preg_replace('/\(\R */', '(', (string) $dql); - $expectedDql = preg_replace('/\R *\)/', ')', $expectedDql); - $expectedDql = preg_replace('/\R */', ' ', $expectedDql); - - if ($expectedDql !== $actualDql) { - throw new \RuntimeException("The DQL:\n'$actualDql' is not equal to:\n'$expectedDql'"); - } - } - - /** - * @Given there are :nb dummy objects - */ - public function thereAreDummyObjects(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDummy('SomeDummyTest'.$i); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->nameConverted = 'Converted '.$i; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb pagination entities - */ - public function thereArePaginationEntities(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $paginationEntity = new PaginationEntity(); - $this->manager->persist($paginationEntity); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb of these so many objects - */ - public function thereAreOfTheseSoManyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $soMany = $this->buildSoMany(); - $soMany->content = 'Many #'.$i; - - $this->manager->persist($soMany); - } - - $this->manager->flush(); - } - - /** - * @When some dummy table inheritance data but not api resource child are created - */ - public function someDummyTableInheritanceDataButNotApiResourceChildAreCreated(): void - { - $dummy = $this->buildDummyTableInheritanceNotApiResourceChild(); - $dummy->setName('Foobarbaz inheritance'); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are :nb foo objects with fake names - */ - public function thereAreFooObjectsWithFakeNames(int $nb): void - { - $names = ['Hawsepipe', 'Sthenelus', 'Ephesian', 'Separativeness', 'Balbo']; - $bars = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; - - for ($i = 0; $i < $nb; ++$i) { - $foo = $this->buildFoo(); - $foo->setName($names[$i]); - $foo->setBar($bars[$i]); - - $this->manager->persist($foo); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb fooDummy objects with fake names - */ - public function thereAreFooDummyObjectsWithFakeNames(int $nb, $embedd = false): void - { - $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; - $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; - - for ($i = 0; $i < $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName($dummies[$i]); - - $foo = $this->buildFooDummy(); - $foo->setName($names[$i]); - if ($embedd) { - $embeddedFoo = $this->buildFooEmbeddable(); - $embeddedFoo->setDummyName('embedded'.$names[$i]); - $foo->setEmbeddedFoo($embeddedFoo); - } - $foo->setDummy($dummy); - for ($j = 0; $j < 3; ++$j) { - $soMany = $this->buildSoMany(); - $soMany->content = "So many $j"; - $soMany->fooDummy = $foo; - $foo->soManies->add($soMany); - } - - $this->manager->persist($foo); - } - - $this->manager->flush(); - } - - /** - * @Given there is a fooDummy objects with fake names and embeddable - */ - public function thereAreFooDummyObjectsWithFakeNamesAndEmbeddable(): void - { - $this->thereAreFooDummyObjectsWithFakeNames(1, true); - } - - /** - * @Given there are :nb dummy group objects - */ - public function thereAreDummyGroupObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz', 'qux'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $this->manager->persist($dummyGroup); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects - */ - public function thereAreDummyPropertyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - $dummyProperty->nameConverted = "NameConverted #$i"; - - $dummyProperty->group = $dummyGroup; - - $this->manager->persist($dummyGroup); - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with a shared group - */ - public function thereAreDummyPropertyObjectsWithASharedGroup(int $nb): void - { - $dummyGroup = $this->buildDummyGroup(); - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #shared'; - } - $this->manager->persist($dummyGroup); - - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = ucfirst($property).' #'.$i; - } - - $dummyProperty->group = $dummyGroup; - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with different number of related groups - */ - public function thereAreDummyPropertyObjectsWithADifferentNumberRelatedGroups(int $nb): void - { - $dummyGroups = []; - for ($i = 1; $i <= $nb; ++$i) { - $dummyGroup = $this->buildDummyGroup(); - $dummyProperty = $this->buildDummyProperty(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $this->manager->persist($dummyGroup); - $dummyGroups[$i] = $dummyGroup; - - for ($j = 1; $j <= $i; ++$j) { - $dummyProperty->groups[] = $dummyGroups[$j]; - } - - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with :nb2 groups - */ - public function thereAreDummyPropertyObjectsWithGroups(int $nb, int $nb2): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $dummyProperty->group = $dummyGroup; - - $this->manager->persist($dummyGroup); - for ($j = 1; $j <= $nb2; ++$j) { - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #'.$i.$j; - } - - $dummyProperty->groups[] = $dummyGroup; - $this->manager->persist($dummyGroup); - } - - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects - */ - public function thereAreEmbeddedDummyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with relatedDummy - */ - public function thereAreDummyObjectsWithRelatedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->nameConverted = "Converted $i"; - $dummy->setRelatedDummy($relatedDummy); - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are dummies with similar properties - */ - public function thereAreDummiesWithSimilarProperties(): void - { - $dummy1 = $this->buildDummy(); - $dummy1->setName('foo'); - $dummy1->setDescription('bar'); - - $dummy2 = $this->buildDummy(); - $dummy2->setName('baz'); - $dummy2->setDescription('qux'); - - $dummy3 = $this->buildDummy(); - $dummy3->setName('foo'); - $dummy3->setDescription('qux'); - - $dummy4 = $this->buildDummy(); - $dummy4->setName('baz'); - $dummy4->setDescription('bar'); - - $this->manager->persist($dummy1); - $this->manager->persist($dummy2); - $this->manager->persist($dummy3); - $this->manager->persist($dummy4); - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyDtoNoInput objects - */ - public function thereAreDummyDtoNoInputObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDto = $this->buildDummyDtoNoInput(); - $dummyDto->lorem = 'DummyDtoNoInput foo #'.$i; - $dummyDto->ipsum = round($i / 3, 2); - - $this->manager->persist($dummyDto); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyDtoNoOutput objects - */ - public function thereAreDummyDtoNoOutputObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDto = $this->buildDummyDtoNoOutput(); - $dummyDto->lorem = 'DummyDtoNoOutput foo #'.$i; - $dummyDto->ipsum = (string) ($i / 3); - - $this->manager->persist($dummyDto); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyCustomQuery objects - */ - public function thereAreDummyCustomQueryObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyCustomQuery = $this->buildDummyCustomQuery(); - - $this->manager->persist($dummyCustomQuery); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyCustomMutation objects - */ - public function thereAreDummyCustomMutationObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $customMutationDummy = $this->buildDummyCustomMutation(); - $customMutationDummy->setOperandA(3); - - $this->manager->persist($customMutationDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with JSON and array data - */ - public function thereAreDummyObjectsWithJsonData(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setJsonData(['foo' => ['bar', 'baz'], 'bar' => 5]); - $dummy->setArrayData(['foo', 'bar', 'baz']); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy with null JSON objects - */ - public function thereAreDummyWithNullJsonObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildWithJsonDummy(); - $dummy->json = null; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with relatedDummy and its thirdLevel - * @Given there is :nb dummy object with relatedDummy and its thirdLevel - */ - public function thereAreDummyObjectsWithRelatedDummyAndItsThirdLevel(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $thirdLevel = $this->buildThirdLevel(); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setRelatedDummy($relatedDummy); - - $this->manager->persist($thirdLevel); - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with :nb relatedDummies and their thirdLevel - */ - public function thereIsADummyObjectWithRelatedDummiesAndTheirThirdLevel(int $nb): void - { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - - for ($i = 1; $i <= $nb; ++$i) { - $thirdLevel = $this->buildThirdLevel(); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy->addRelatedDummy($relatedDummy); - - $this->manager->persist($thirdLevel); - $this->manager->persist($relatedDummy); - } - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with :nb relatedDummies with same thirdLevel - */ - public function thereIsADummyObjectWithRelatedDummiesWithSameThirdLevel(int $nb): void - { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - $thirdLevel = $this->buildThirdLevel(); - - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy->addRelatedDummy($relatedDummy); - - $this->manager->persist($relatedDummy); - } - $this->manager->persist($thirdLevel); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with embeddedDummy - */ - public function thereAreDummyObjectsWithEmbeddedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('EmbeddedDummy #'.$i); - - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects having each :nbrelated relatedDummies - */ - public function thereAreDummyObjectsWithRelatedDummies(int $nb, int $nbrelated): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - - for ($j = 1; $j <= $nbrelated; ++$j) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy'.$j.$i); - $relatedDummy->setAge((int) ($j.$i)); - $this->manager->persist($relatedDummy); - - $dummy->addRelatedDummy($relatedDummy); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb multiRelationsDummy objects having each :nbmtor manyToOneRelation, :nbmtmr manyToManyRelations, :nbotmr oneToManyRelations and :nber embeddedRelations - */ - public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsOneToManyRelationsAndEmbeddedRelations(int $nb, int $nbmtor, int $nbmtmr, int $nbotmr, int $nber): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildMultiRelationsRelatedDummy(); - $relatedDummy->name = 'RelatedManyToOneDummy #'.$i; - - $resolveDummy = $this->buildMultiRelationsResolveDummy(); - $resolveDummy->name = 'RelatedManyToOneResolveDummy #'.$i; - - $dummy = $this->buildMultiRelationsDummy(); - $dummy->name = 'Dummy #'.$i; - - if ($nbmtor) { - $dummy->setManyToOneRelation($relatedDummy); - $dummy->setManyToOneResolveRelation($resolveDummy); - } - - for ($j = 1; $j <= $nbmtmr; ++$j) { - $manyToManyItem = $this->buildMultiRelationsRelatedDummy(); - $manyToManyItem->name = 'RelatedManyToManyDummy'.$j.$i; - $this->manager->persist($manyToManyItem); - - $dummy->addManyToManyRelation($manyToManyItem); - } - - for ($j = 1; $j <= $nbotmr; ++$j) { - $oneToManyItem = $this->buildMultiRelationsRelatedDummy(); - $oneToManyItem->name = 'RelatedOneToManyDummy'.$j.$i; - $oneToManyItem->setOneToManyRelation($dummy); - $this->manager->persist($oneToManyItem); - - $dummy->addOneToManyRelation($oneToManyItem); - } - - $nested = new ArrayCollection(); - for ($j = 1; $j <= $nber; ++$j) { - $embeddedItem = $this->buildMultiRelationsNested(); - $embeddedItem->name = 'NestedDummy'.$j; - $nested->add($embeddedItem); - } - $dummy->setNestedCollection($nested); - - $nestedPaginated = new ArrayCollection(); - for ($j = 1; $j <= $nber; ++$j) { - $embeddedItem = $this->buildMultiRelationsNestedPaginated(); - $embeddedItem->name = 'NestedPaginatedDummy'.$j; - $nestedPaginated->add($embeddedItem); - } - $dummy->setNestedPaginatedCollection($nestedPaginated); - - $this->manager->persist($relatedDummy); - $this->manager->persist($resolveDummy); - $this->manager->persist($dummy); - } - $this->manager->flush(); - } - - /** - * @Given there are tree dummies - */ - public function thereAreTreeDummies(): void - { - $parentDummy = new TreeDummy(); - $this->manager->persist($parentDummy); - - $childDummy = new TreeDummy(); - $childDummy->setParent($parentDummy); - - $this->manager->persist($childDummy); - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate - * @Given there is :nb dummy object with dummyDate - */ - public function thereAreDummyObjectsWithDummyDate(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate and dummyBoolean :bool - */ - public function thereAreDummyObjectsWithDummyDateAndDummyBoolean(int $nb, string $bool): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyBoolean($bool); - - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate and relatedDummy - */ - public function thereAreDummyObjectsWithDummyDateAndRelatedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setDummyDate($date); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setRelatedDummy($relatedDummy); - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with dummyDate and embeddedDummy - */ - public function thereAreDummyObjectsWithDummyDateAndEmbeddedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embeddable #'.$i); - $embeddableDummy->setDummyDate($date); - - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedDate objects - */ - public function thereAreconvertedDateObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedDate = $this->buildConvertedDate(); - $convertedDate->nameConverted = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $this->manager->persist($convertedDate); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedString objects - */ - public function thereAreconvertedStringObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedString = $this->buildConvertedString(); - $convertedString->nameConverted = ($i % 2) ? "name#$i" : null; - - $this->manager->persist($convertedString); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedBoolean objects - */ - public function thereAreconvertedBooleanObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedBoolean = $this->buildConvertedBoolean(); - $convertedBoolean->nameConverted = (bool) ($i % 2); - - $this->manager->persist($convertedBoolean); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedInteger objects - */ - public function thereAreconvertedIntegerObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedInteger = $this->buildConvertedInteger(); - $convertedInteger->nameConverted = $i; - - $this->manager->persist($convertedInteger); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyPrice - */ - public function thereAreDummyObjectsWithDummyPrice(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - $prices = ['9.99', '12.99', '15.99', '19.99']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyPrice($prices[($i - 1) % 4]); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyBoolean :bool - * @Given there is :nb dummy object with dummyBoolean :bool - */ - public function thereAreDummyObjectsWithDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyBoolean($bool); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with embeddedDummy.dummyBoolean :bool - */ - public function thereAreDummyObjectsWithEmbeddedDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Embedded Dummy #'.$i); - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embedded Dummy #'.$i); - $embeddableDummy->setDummyBoolean($bool); - $dummy->setEmbeddedDummy($embeddableDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean :bool - */ - public function thereAreDummyObjectsWithRelationEmbeddedDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Embedded Dummy #'.$i); - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embedded Dummy #'.$i); - $embeddableDummy->setDummyBoolean($bool); - - $relationDummy = $this->buildRelatedDummy(); - $relationDummy->setEmbeddedDummy($embeddableDummy); - - $dummy->setRelatedDummy($relationDummy); - - $this->manager->persist($relationDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb SecuredDummy objects - */ - public function thereAreSecuredDummyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $securedDummy = $this->buildSecuredDummy(); - $securedDummy->setTitle("#$i"); - $securedDummy->setDescription("Hello #$i"); - $securedDummy->setOwner('notexist'); - - $this->manager->persist($securedDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb SecuredDummy objects owned by :ownedby with related dummies - */ - public function thereAreSecuredDummyObjectsOwnedByWithRelatedDummies(int $nb, string $ownedby): void - { - for ($i = 1; $i <= $nb; ++$i) { - $securedDummy = $this->buildSecuredDummy(); - $securedDummy->setTitle("#$i"); - $securedDummy->setDescription("Hello #$i"); - $securedDummy->setOwner($ownedby); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy'); - $this->manager->persist($relatedDummy); - - $relatedSecuredDummy = $this->buildRelatedSecureDummy(); - $this->manager->persist($relatedSecuredDummy); - - $publicRelatedSecuredDummy = $this->buildRelatedSecureDummy(); - $this->manager->persist($publicRelatedSecuredDummy); - - $relatedLinkedDummy = $this->buildRelatedLinkedDummy(); - $this->manager->persist($relatedLinkedDummy); - - $securedDummy->addRelatedDummy($relatedDummy); - $securedDummy->setRelatedDummy($relatedDummy); - $securedDummy->addRelatedSecuredDummy($relatedSecuredDummy); - $securedDummy->setRelatedSecuredDummy($relatedSecuredDummy); - $securedDummy->addPublicRelatedSecuredDummy($publicRelatedSecuredDummy); - $securedDummy->setPublicRelatedSecuredDummy($publicRelatedSecuredDummy); - $relatedLinkedDummy->setSecuredDummy($securedDummy); - - $this->manager->persist($securedDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a RelationEmbedder object - */ - public function thereIsARelationEmbedderObject(): void - { - $relationEmbedder = $this->buildRelationEmbedder(); - - $this->manager->persist($relationEmbedder); - $this->manager->flush(); - } - - /** - * @Given there is a Dummy Object mapped by UUID - */ - public function thereIsADummyObjectMappedByUUID(): void - { - $dummy = new UuidIdentifierDummy(); - $dummy->setName('My Dummy'); - $dummy->setUuid('41B29566-144B-11E6-A148-3E1D05DEFE78'); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are Composite identifier objects - */ - public function thereIsACompositeIdentifierObject(): void - { - $item = $this->buildCompositeItem(); - $item->setField1('foobar'); - $this->manager->persist($item); - $this->manager->flush(); - - for ($i = 0; $i < 4; ++$i) { - $label = $this->buildCompositeLabel(); - $label->setValue('foo-'.$i); - - $rel = $this->buildCompositeRelation(); - $rel->setCompositeLabel($label); - $rel->setCompositeItem($item); - $rel->setValue('somefoobardummy'); - - $this->manager->persist($label); - // since doctrine 2.6 we need existing identifiers on relations - $this->manager->flush(); - $this->manager->persist($rel); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there are composite primitive identifiers objects - */ - public function thereAreCompositePrimitiveIdentifiersObjects(): void - { - $foo = $this->buildCompositePrimitiveItem('Foo', 2016); - $foo->setDescription('This is foo.'); - $this->manager->persist($foo); - - $bar = $this->buildCompositePrimitiveItem('Bar', 2017); - $bar->setDescription('This is bar.'); - $this->manager->persist($bar); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a FileConfigDummy object - */ - public function thereIsAFileConfigDummyObject(): void - { - $fileConfigDummy = $this->buildFileConfigDummy(); - $fileConfigDummy->setName('ConfigDummy'); - $fileConfigDummy->setFoo('Foo'); - - $this->manager->persist($fileConfigDummy); - $this->manager->flush(); - } - - /** - * @Given there is a DummyCar entity with related colors - */ - public function thereIsAFooEntityWithRelatedBars(): void - { - $foo = $this->buildDummyCar(); - $foo->setName('mustli'); - $foo->setCanSell(true); - $foo->setAvailableAt(new \DateTime()); - $this->manager->persist($foo); - $this->manager->flush(); - - if (\is_object($foo->getId())) { - $this->manager->persist($foo->getId()); - $this->manager->flush(); - } - - $bar1 = $this->buildDummyCarColor(); - $bar1->setProp('red'); - $bar1->setCar($foo); - $this->manager->persist($bar1); - $this->manager->flush(); - - $bar2 = $this->buildDummyCarColor(); - $bar2->setProp('blue'); - $bar2->setCar($foo); - $this->manager->persist($bar2); - $this->manager->flush(); - - $foo->setColors(new ArrayCollection([$bar1, $bar2])); - $this->manager->persist($foo); - $this->manager->flush(); - } - - /** - * @Given there is a dummy travel - */ - public function thereIsADummyTravel(): void - { - $car = $this->buildDummyCar(); - $car->setName('model x'); - $car->setCanSell(true); - $car->setAvailableAt(new \DateTime()); - $this->manager->persist($car); - - $passenger = $this->buildDummyPassenger(); - $passenger->nickname = 'Tom'; - $this->manager->persist($passenger); - - $travel = $this->buildDummyTravel(); - $travel->car = $car; - $travel->passenger = $passenger; - $travel->confirmed = true; - $this->manager->persist($travel); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedDummy with :nb friends - */ - public function thereIsARelatedDummyWithFriends(int $nb): void - { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy with friends'); - $this->manager->persist($relatedDummy); - $this->manager->flush(); - - for ($i = 1; $i <= $nb; ++$i) { - $friend = $this->buildDummyFriend(); - $friend->setName('Friend-'.$i); - - $this->manager->persist($friend); - // since doctrine 2.6 we need existing identifiers on relations - // See https://github.com/doctrine/doctrine2/pull/6701 - $this->manager->flush(); - - $relation = $this->buildRelatedToDummyFriend(); - $relation->setName('Relation-'.$i); - $relation->setDummyFriend($friend); - $relation->setRelatedDummy($relatedDummy); - - $relatedDummy->addRelatedToDummyFriend($relation); - - $this->manager->persist($relation); - } - - $relatedDummy2 = $this->buildRelatedDummy(); - $relatedDummy2->setName('RelatedDummy without friends'); - $this->manager->persist($relatedDummy2); - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is an answer :answer to the question :question - */ - public function thereIsAnAnswerToTheQuestion(string $a, string $q): void - { - $answer = $this->buildAnswer(); - $answer->setContent($a); - - $question = $this->buildQuestion(); - $question->setContent($q); - $question->setAnswer($answer); - $answer->addRelatedQuestion($question); - - $this->manager->persist($answer); - $this->manager->persist($question); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a UrlEncodedId resource - */ - public function thereIsAUrlEncodedIdResource(): void - { - $urlEncodedIdResource = ($this->isOrm() ? new UrlEncodedId() : new UrlEncodedIdDocument()); - $this->manager->persist($urlEncodedIdResource); - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a Program - */ - public function thereIsAProgram(): void - { - $this->thereArePrograms(1); - } - - /** - * @Given there are :nb Programs - */ - public function thereArePrograms(int $nb): void - { - $author = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find(1); - if (null === $author) { - $author = $this->isOrm() ? new User() : new UserDocument(); - $author->setEmail('john.doe@example.com'); - $author->setFullname('John DOE'); - $author->setPlainPassword('p4$$w0rd'); - - $this->manager->persist($author); - $this->manager->flush(); - } - - if ($this->isOrm()) { - $count = $this->doctrine->getRepository(Program::class)->count(['author' => $author]); - } else { - /** @var Builder */ - $qb = $this->doctrine->getRepository(ProgramDocument::class) - ->createQueryBuilder('f'); - $count = $qb->field('author')->equals($author) - ->count()->getQuery()->execute(); - } - - for ($i = (int) $count + 1; $i <= $nb; ++$i) { - $program = $this->isOrm() ? new Program() : new ProgramDocument(); - $program->name = "Lorem ipsum $i"; - $program->date = new \DateTimeImmutable(\sprintf('2015-03-0%dT10:00:00+00:00', $i)); - $program->author = $author; - - $this->manager->persist($program); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a Comment - */ - public function thereIsAComment(): void - { - $this->thereAreComments(1); - } - - /** - * @Given there are :nb Comments - */ - public function thereAreComments(int $nb): void - { - $author = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find(1); - if (null === $author) { - $author = $this->isOrm() ? new User() : new UserDocument(); - $author->setEmail('john.doe@example.com'); - $author->setFullname('John DOE'); - $author->setPlainPassword('p4$$w0rd'); - - $this->manager->persist($author); - $this->manager->flush(); - } - - if ($this->isOrm()) { - $count = $this->doctrine->getRepository(Comment::class)->count(['author' => $author]); - } else { - /** @var Builder $qb */ - $qb = $this->doctrine->getRepository(CommentDocument::class) - ->createQueryBuilder('f'); - - $count = $qb->field('author')->equals($author) - ->count()->getQuery()->execute(); - } - - for ($i = (int) $count + 1; $i <= $nb; ++$i) { - $comment = $this->isOrm() ? new Comment() : new CommentDocument(); - $comment->comment = "Lorem ipsum dolor sit amet $i"; - $comment->date = new \DateTimeImmutable(\sprintf('2015-03-0%dT10:00:00+00:00', $i)); - $comment->author = $author; - - $this->manager->persist($comment); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Then the password :password for user :user should be hashed - */ - public function thePasswordForUserShouldBeHashed(string $password, string $user): void - { - $user = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find($user); - if (!$this->passwordHasher->isPasswordValid($user, $password)) { - throw new \Exception('User password mismatch'); - } - } - - /** - * @Given I have a product with offers - */ - public function createProductWithOffers(): void - { - $offer = $this->buildDummyOffer(); - $offer->setId(1); - $offer->setValue(2); - - $aggregate = $this->buildDummyAggregateOffer(); - $aggregate->setValue(1); - $aggregate->addOffer($offer); - - $product = $this->buildDummyProduct(); - $product->setId(2); - $product->setName('Dummy product'); - $product->addOffer($aggregate); - - $relatedProduct = $this->buildDummyProduct(); - $relatedProduct->setName('Dummy related product'); - $relatedProduct->setId(1); - $relatedProduct->setParent($product); - - $product->addRelatedProduct($relatedProduct); - - $this->manager->persist($relatedProduct); - $this->manager->persist($product); - $this->manager->flush(); - } - - /** - * @Given there are people having pets - */ - public function createPeopleWithPets(): void - { - $personToPet = $this->buildPersonToPet(); - - $person = $this->buildPerson(); - $person->name = 'foo'; - - $pet = $this->buildPet(); - $pet->name = 'bar'; - - $personToPet->person = $person; - $personToPet->pet = $pet; - - $this->manager->persist($person); - $this->manager->persist($pet); - // since doctrine 2.6 we need existing identifiers on relations - $this->manager->flush(); - $this->manager->persist($personToPet); - - $person->pets->add($personToPet); - $this->manager->persist($person); - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with dummyDate - * @Given there is :nb dummydate object with dummyDate - */ - public function thereAreDummyDateObjectsWithDummyDate(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullAfter - * @Given there is :nb dummydate object with nullable dateIncludeNullAfter - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullAfter(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullAfter = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullBefore - * @Given there is :nb dummydate object with nullable dateIncludeNullBefore - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullBefore(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullBefore = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullBeforeAndAfter - * @Given there is :nb dummydate object with nullable dateIncludeNullBeforeAndAfter - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullBeforeAndAfter(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullBeforeAndAfter = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyimmutabledate objects with dummyDate - */ - public function thereAreDummyImmutableDateObjectsWithDummyDate(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTimeImmutable(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - $dummy = $this->buildDummyImmutableDate(); - $dummy->dummyDate = $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy with different GraphQL serialization groups objects - */ - public function thereAreDummyWithDifferentGraphQlSerializationGroupsObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDifferentGraphQlSerializationGroup = $this->buildDummyDifferentGraphQlSerializationGroup(); - $dummyDifferentGraphQlSerializationGroup->setName('Name #'.$i); - $dummyDifferentGraphQlSerializationGroup->setTitle('Title #'.$i); - $this->manager->persist($dummyDifferentGraphQlSerializationGroup); - } - - $this->manager->flush(); - } - - /** - * @Given there is a ramsey identified resource with uuid :uuid - * - * @param non-empty-string $uuid - */ - public function thereIsARamseyIdentifiedResource(string $uuid): void - { - $dummy = new RamseyUuidDummy(Uuid::fromString($uuid)); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a Symfony dummy identified resource with uuid :uuid - */ - public function thereIsASymfonyDummyIdentifiedResource(string $uuid): void - { - $dummy = new SymfonyUuidDummy(SymfonyUuid::fromString($uuid)); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with a fourth level relation - */ - public function thereIsADummyObjectWithAFourthLevelRelation(): void - { - $fourthLevel = $this->buildFourthLevel(); - $fourthLevel->setLevel(4); - $this->manager->persist($fourthLevel); - - $thirdLevel = $this->buildThirdLevel(); - $thirdLevel->setLevel(3); - $thirdLevel->setFourthLevel($fourthLevel); - $this->manager->persist($thirdLevel); - - $namedRelatedDummy = $this->buildRelatedDummy(); - $namedRelatedDummy->setName('Hello'); - $namedRelatedDummy->setThirdLevel($thirdLevel); - $this->manager->persist($namedRelatedDummy); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setThirdLevel($thirdLevel); - $this->manager->persist($relatedDummy); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - $dummy->setRelatedDummy($namedRelatedDummy); - $dummy->addRelatedDummy($namedRelatedDummy); - $dummy->addRelatedDummy($relatedDummy); - $this->manager->persist($dummy); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedOwnedDummy object with OneToOne relation - */ - public function thereIsARelatedOwnedDummy(): void - { - $relatedOwnedDummy = $this->buildRelatedOwnedDummy(); - $this->manager->persist($relatedOwnedDummy); - - $dummy = $this->buildDummy(); - $dummy->setName('plop'); - $dummy->setRelatedOwnedDummy($relatedOwnedDummy); - $this->manager->persist($dummy); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedOwningDummy object with OneToOne relation - */ - public function thereIsARelatedOwningDummy(): void - { - $dummy = $this->buildDummy(); - $dummy->setName('plop'); - $this->manager->persist($dummy); - - $relatedOwningDummy = $this->buildRelatedOwningDummy(); - $relatedOwningDummy->setOwnedDummy($dummy); - $this->manager->persist($relatedOwningDummy); - - $this->manager->flush(); - } - - /** - * @Given there is a person named :name greeting with a :message message - */ - public function thereIsAPersonWithAGreeting(string $name, string $message): void - { - $person = $this->buildPerson(); - $person->name = $name; - - $greeting = $this->buildGreeting(); - $greeting->message = $message; - $greeting->sender = $person; - - $this->manager->persist($person); - $this->manager->persist($greeting); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a max depth dummy with :level level of descendants - */ - public function thereIsAMaxDepthDummyWithLevelOfDescendants(int $level): void - { - $maxDepthDummy = $this->buildMaxDepthDummy(); - $maxDepthDummy->name = "level $level"; - $this->manager->persist($maxDepthDummy); - - for ($i = 1; $i <= $level; ++$i) { - $maxDepthDummy = $maxDepthDummy->child = $this->buildMaxDepthDummy(); - $maxDepthDummy->name = 'level '.($i + 1); - $this->manager->persist($maxDepthDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a DummyDtoCustom - */ - public function thereIsADummyDtoCustom(): void - { - $this->thereAreNbDummyDtoCustom(1); - } - - /** - * @Given there are :nb DummyDtoCustom - */ - public function thereAreNbDummyDtoCustom($nb): void - { - for ($i = 0; $i < $nb; ++$i) { - $dto = $this->isOrm() ? new DummyDtoCustom() : new DummyDtoCustomDocument(); - $dto->lorem = 'test'; - $dto->ipsum = (string) ($i + 1); - $this->manager->persist($dto); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is an order with same customer and recipient - */ - public function thereIsAnOrderWithSameCustomerAndRecipient(): void - { - $customer = $this->isOrm() ? new Customer() : new CustomerDocument(); - $customer->name = 'customer_name'; - - $address1 = $this->isOrm() ? new Address() : new AddressDocument(); - $address1->name = 'foo'; - $address2 = $this->isOrm() ? new Address() : new AddressDocument(); - $address2->name = 'bar'; - - $order = $this->isOrm() ? new Order() : new OrderDocument(); - $order->recipient = $customer; - $order->customer = $customer; - - $customer->addresses->add($address1); - $customer->addresses->add($address2); - - $this->manager->persist($address1); - $this->manager->persist($address2); - $this->manager->persist($customer); - $this->manager->persist($order); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there are :nb sites with internal owner - */ - public function thereAreSitesWithInternalOwner(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $internalUser = new InternalUser(); - $internalUser->setFirstname('Internal'); - $internalUser->setLastname('User'); - $internalUser->setEmail('john.doe@example.com'); - $internalUser->setInternalId('INT'); - $site = new Site(); - $site->setTitle('title'); - $site->setDescription('description'); - $site->setOwner($internalUser); - $this->manager->persist($site); - } - $this->manager->flush(); - } - - /** - * @Given there are :nb sites with external owner - */ - public function thereAreSitesWithExternalOwner(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $externalUser = new ExternalUser(); - $externalUser->setFirstname('External'); - $externalUser->setLastname('User'); - $externalUser->setEmail('john.doe@example.com'); - $externalUser->setExternalId('EXT'); - $site = new Site(); - $site->setTitle('title'); - $site->setDescription('description'); - $site->setOwner($externalUser); - $this->manager->persist($site); - } - $this->manager->flush(); - } - - /** - * @Given there is the following taxon: - */ - public function thereIsTheFollowingTaxon(PyStringNode $dataNode): void - { - $data = json_decode((string) $dataNode, true, 512, \JSON_THROW_ON_ERROR); - - $taxon = $this->isOrm() ? new Taxon() : new TaxonDocument(); - $taxon->setCode($data['code']); - $this->manager->persist($taxon); - - $this->manager->flush(); - } - - /** - * @Given there is the following product: - */ - public function thereIsTheFollowingProduct(PyStringNode $dataNode): void - { - $data = json_decode((string) $dataNode, true, 512, \JSON_THROW_ON_ERROR); - - $product = $this->isOrm() ? new Product() : new ProductDocument(); - $product->setCode($data['code']); - if (isset($data['mainTaxon'])) { - $mainTaxonCode = str_replace('/taxa/', '', $data['mainTaxon']); - $mainTaxon = $this->manager->getRepository($this->isOrm() ? Taxon::class : TaxonDocument::class)->findOneBy([ - 'code' => $mainTaxonCode, - ]); - $product->setMainTaxon($mainTaxon); - } - $this->manager->persist($product); - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedOwner objects with convertedRelated - */ - public function thereAreConvertedOwnerObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $related = $this->buildConvertedRelated(); - $related->nameConverted = 'Converted '.$i; - - $owner = $this->buildConvertedOwner(); - $owner->nameConverted = $related; - - $this->manager->persist($related); - $this->manager->persist($owner); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy mercure objects - */ - public function thereAreDummyMercureObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - - $dummyMercure = $this->buildDummyMercure(); - $dummyMercure->name = "Dummy Mercure #$i"; - $dummyMercure->description = 'Description'; - $dummyMercure->relatedDummy = $relatedDummy; - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummyMercure); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb iriOnlyDummies - */ - public function thereAreIriOnlyDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $iriOnlyDummy = $this->buildIriOnlyDummy(); - $iriOnlyDummy->setFoo('bar'.$nb); - $this->manager->persist($iriOnlyDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are propertyCollectionIriOnly with relations - */ - public function thereAreResourcesWithPropertyUriTemplates(): void - { - $propertyCollectionIriOnlyRelation1 = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument(); - $propertyCollectionIriOnlyRelation1->name = 'asb1'; - - $propertyCollectionIriOnlyRelation2 = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument(); - $propertyCollectionIriOnlyRelation2->name = 'asb2'; - - $propertyToOneRelation = $this->isOrm() ? new PropertyUriTemplateOneToOneRelation() : new PropertyUriTemplateOneToOneRelationDocument(); - $propertyToOneRelation->name = 'xarguš'; - - $propertyCollectionIriOnly = $this->isOrm() ? new PropertyCollectionIriOnly() : new PropertyCollectionIriOnlyDocument(); - $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation1); - $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation2); - $propertyCollectionIriOnly->setToOneRelation($propertyToOneRelation); - - $this->manager->persist($propertyCollectionIriOnly); - $this->manager->persist($propertyCollectionIriOnlyRelation1); - $this->manager->persist($propertyCollectionIriOnlyRelation2); - $this->manager->persist($propertyToOneRelation); - $this->manager->flush(); - } - - /** - * @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy - */ - public function thereAreAbsoluteUrlDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $absoluteUrlRelationDummy = $this->buildAbsoluteUrlRelationDummy(); - $absoluteUrlDummy = $this->buildAbsoluteUrlDummy(); - $absoluteUrlDummy->absoluteUrlRelationDummy = $absoluteUrlRelationDummy; - - $this->manager->persist($absoluteUrlRelationDummy); - $this->manager->persist($absoluteUrlDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb networkPathDummy objects with a related networkPathRelationDummy - */ - public function thereAreNetworkPathDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $networkPathRelationDummy = $this->buildNetworkPathRelationDummy(); - $networkPathDummy = $this->buildNetworkPathDummy(); - $networkPathDummy->networkPathRelationDummy = $networkPathRelationDummy; - - $this->manager->persist($networkPathRelationDummy); - $this->manager->persist($networkPathDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is an InitializeInput object with id :id - */ - public function thereIsAnInitializeInput(int $id): void - { - $initializeInput = $this->buildInitializeInput(); - $initializeInput->id = $id; - $initializeInput->manager = 'Orwell'; - $initializeInput->name = '1984'; - - $this->manager->persist($initializeInput); - $this->manager->flush(); - } - - /** - * @Given there is a PatchDummyRelation - */ - public function thereIsAPatchDummyRelation(): void - { - $dummy = $this->buildPatchDummyRelation(); - $related = $this->buildRelatedDummy(); - $this->manager->persist($related); - $this->manager->flush(); - $dummy->setRelated($related); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a book - */ - public function thereIsABook(): void - { - $book = $this->buildBook(); - $book->name = '1984'; - $book->isbn = '9780451524935'; - $this->manager->persist($book); - $this->manager->flush(); - } - - /** - * @Given there is a custom multiple identifier dummy - */ - public function thereIsACustomMultipleIdentifierDummy(): void - { - $dummy = $this->buildCustomMultipleIdentifierDummy(); - $dummy->setName('Orwell'); - $dummy->setFirstId(1); - $dummy->setSecondId(2); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a payment - */ - public function thereIsAPayment(): void - { - $this->manager->persist($this->buildPayment('123.45')); - $this->manager->flush(); - } - - /** - * @Given there are :nb separated entities - */ - public function thereAreSeparatedEntities(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $entity = $this->buildSeparatedEntity(); - $entity->value = (string) $i; - $this->manager->persist($entity); - } - $this->manager->flush(); - } - - /** - * @Given there is a video game with music groups - */ - public function thereAreVideoGamesWithMusicGroups(): void - { - $sum41 = $this->buildMusicGroup(); - $sum41->name = 'Sum 41'; - $this->manager->persist($sum41); - $franz = $this->buildMusicGroup(); - $franz->name = 'Franz Ferdinand'; - $this->manager->persist($franz); - - $videoGame = $this->buildVideoGame(); - $videoGame->name = 'Guitar Hero'; - $videoGame->addMusicGroup($sum41); - $videoGame->addMusicGroup($franz); - $this->manager->persist($videoGame); - $this->manager->flush(); - } - - /** - * @Given there is a relationMultiple object - */ - public function thereIsARelationMultipleObject(): void - { - $first = $this->buildDummy(); - $first->setId(1); - $first->setName('foo'); - $second = $this->buildDummy(); - $second->setId(2); - $second->setName('bar'); - - $relationMultiple = (new RelationMultiple()); - $relationMultiple->first = $first; - $relationMultiple->second = $second; - - $this->manager->persist($first); - $this->manager->persist($second); - $this->manager->persist($relationMultiple); - - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with many multiple relation - */ - public function thereIsADummyObjectWithManyMultipleRelation(): void - { - $first = $this->buildDummy(); - $first->setId(1); - $first->setName('foo'); - $second = $this->buildDummy(); - $second->setId(2); - $second->setName('bar'); - $third = $this->buildDummy(); - $third->setId(3); - $third->setName('foobar'); - - $relationMultiple1 = (new RelationMultiple()); - $relationMultiple1->first = $first; - $relationMultiple1->second = $second; - - $relationMultiple2 = (new RelationMultiple()); - $relationMultiple2->first = $first; - $relationMultiple2->second = $third; - - $this->manager->persist($first); - $this->manager->persist($second); - $this->manager->persist($third); - $this->manager->persist($relationMultiple1); - $this->manager->persist($relationMultiple2); - - $this->manager->flush(); - } - - /** - * @Given there is a resource using entityClass with a DateTime attribute - */ - public function thereIsAResourceUsingEntityClassAndDateTime(): void - { - $entity = new EntityClassWithDateTime(); - $entity->setStart(new \DateTime()); - $this->manager->persist($entity); - $this->manager->flush(); - } - - /** - * @Given there is a dummy entity with a sub entity with id :strId and name :name - */ - public function thereIsADummyWithSubEntity(string $strId, string $name): void - { - $subEntity = new DummySubEntity($strId, $name); - $mainEntity = new DummyWithSubEntity(); - $mainEntity->setSubEntity($subEntity); - $mainEntity->setName('main'); - $this->manager->persist($subEntity); - $this->manager->persist($mainEntity); - $this->manager->flush(); - } - - /** - * @Given there is a group object with uuid :uuid and :nbUsers users - */ - public function thereIsAGroupWithUuidAndNUsers(string $uuid, int $nbUsers): void - { - $group = new Group(); - $group->setUuid(SymfonyUuid::fromString($uuid)); - - $this->manager->persist($group); - - for ($i = 0; $i < $nbUsers; ++$i) { - $user = new \ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User(); - $user->addGroup($group); - $this->manager->persist($user); - } - - // add another user not in this group - $user = new \ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User(); - $this->manager->persist($user); - - $this->manager->flush(); - } - - /** - * @Given there are logs on an event - */ - public function thereAreLogsOnAnEvent(): void - { - $entity = new Event(); - $entity->logs = new ArrayCollection([new ItemLog(), new ItemLog()]); - $entity->uuid = Uuid::fromString('03af3507-271e-4cca-8eee-6244fb06e95b'); - $this->manager->persist($entity); - foreach ($entity->logs as $log) { - $log->item = $entity; - $this->manager->persist($log); - } - - $this->manager->flush(); - } - - /** - * @Given there are a few link handled dummies - */ - public function thereAreAFewLinkHandledDummies(): void - { - $this->manager->persist($this->buildLinkHandledDummy('foo')); - $this->manager->persist($this->buildLinkHandledDummy('bar')); - $this->manager->persist($this->buildLinkHandledDummy('baz')); - $this->manager->persist($this->buildLinkHandledDummy('foz')); - $this->manager->flush(); - } - - /** - * @Given there is a dummy entity with a mapped superclass - */ - public function thereIsADummyEntityWithAMappedSuperclass(): void - { - $entity = new DummyMappedSubclass(); - $this->manager->persist($entity); - $this->manager->flush(); - } - - /** - * @Given there are issue6039 users - */ - public function thereAreIssue6039Users(): void - { - $entity = new Issue6039EntityUser(); - $entity->name = 'test'; - $entity->bar = 'test'; - $this->manager->persist($entity); - $entity = new Issue6039EntityUser(); - $entity->name = 'test2'; - $entity->bar = 'test'; - $this->manager->persist($entity); - $this->manager->flush(); - } - - private function isOrm(): bool - { - return null !== $this->schemaTool; - } - - private function isOdm(): bool - { - return null !== $this->schemaManager; - } - - private function buildAnswer(): Answer|AnswerDocument - { - return $this->isOrm() ? new Answer() : new AnswerDocument(); - } - - private function buildCompositeItem(): CompositeItem|CompositeItemDocument - { - return $this->isOrm() ? new CompositeItem() : new CompositeItemDocument(); - } - - private function buildCompositeLabel(): CompositeLabel|CompositeLabelDocument - { - return $this->isOrm() ? new CompositeLabel() : new CompositeLabelDocument(); - } - - private function buildCompositePrimitiveItem(string $name, int $year): CompositePrimitiveItem|CompositePrimitiveItemDocument - { - return $this->isOrm() ? new CompositePrimitiveItem($name, $year) : new CompositePrimitiveItemDocument($name, $year); - } - - private function buildCompositeRelation(): CompositeRelation|CompositeRelationDocument - { - return $this->isOrm() ? new CompositeRelation() : new CompositeRelationDocument(); - } - - private function buildDummy(): Dummy|DummyDocument - { - return $this->isOrm() ? new Dummy() : new DummyDocument(); - } - - private function buildDummyTableInheritanceNotApiResourceChild(): DummyTableInheritanceNotApiResourceChild|DummyTableInheritanceNotApiResourceChildDocument - { - return $this->isOrm() ? new DummyTableInheritanceNotApiResourceChild() : new DummyTableInheritanceNotApiResourceChildDocument(); - } - - private function buildDummyAggregateOffer(): DummyAggregateOffer|DummyAggregateOfferDocument - { - return $this->isOrm() ? new DummyAggregateOffer() : new DummyAggregateOfferDocument(); - } - - private function buildDummyCar(): DummyCar|DummyCarDocument - { - return $this->isOrm() ? new DummyCar() : new DummyCarDocument(); - } - - private function buildDummyCarColor(): DummyCarColor|DummyCarColorDocument - { - return $this->isOrm() ? new DummyCarColor() : new DummyCarColorDocument(); - } - - private function buildDummyPassenger(): DummyPassenger|DummyPassengerDocument - { - return $this->isOrm() ? new DummyPassenger() : new DummyPassengerDocument(); - } - - private function buildDummyTravel(): DummyTravel|DummyTravelDocument - { - return $this->isOrm() ? new DummyTravel() : new DummyTravelDocument(); - } - - private function buildDummyDate(): DummyDate|DummyDateDocument - { - return $this->isOrm() ? new DummyDate() : new DummyDateDocument(); - } - - private function buildDummyImmutableDate(): DummyImmutableDate|DummyImmutableDateDocument - { - return $this->isOrm() ? new DummyImmutableDate() : new DummyImmutableDateDocument(); - } - - private function buildDummyDifferentGraphQlSerializationGroup(): DummyDifferentGraphQlSerializationGroup|DummyDifferentGraphQlSerializationGroupDocument - { - return $this->isOrm() ? new DummyDifferentGraphQlSerializationGroup() : new DummyDifferentGraphQlSerializationGroupDocument(); - } - - private function buildDummyDtoNoInput(): DummyDtoNoInput|DummyDtoNoInputDocument - { - return $this->isOrm() ? new DummyDtoNoInput() : new DummyDtoNoInputDocument(); - } - - private function buildDummyDtoNoOutput(): DummyDtoNoOutput|DummyDtoNoOutputDocument - { - return $this->isOrm() ? new DummyDtoNoOutput() : new DummyDtoNoOutputDocument(); - } - - private function buildDummyCustomQuery(): DummyCustomQuery|DummyCustomQueryDocument - { - return $this->isOrm() ? new DummyCustomQuery() : new DummyCustomQueryDocument(); - } - - private function buildDummyCustomMutation(): DummyCustomMutation|DummyCustomMutationDocument - { - return $this->isOrm() ? new DummyCustomMutation() : new DummyCustomMutationDocument(); - } - - private function buildDummyFriend(): DummyFriend|DummyFriendDocument - { - return $this->isOrm() ? new DummyFriend() : new DummyFriendDocument(); - } - - private function buildDummyGroup(): DummyGroup|DummyGroupDocument - { - return $this->isOrm() ? new DummyGroup() : new DummyGroupDocument(); - } - - private function buildDummyOffer(): DummyOffer|DummyOfferDocument - { - return $this->isOrm() ? new DummyOffer() : new DummyOfferDocument(); - } - - private function buildDummyProduct(): DummyProduct|DummyProductDocument - { - return $this->isOrm() ? new DummyProduct() : new DummyProductDocument(); - } - - private function buildDummyProperty(): DummyProperty|DummyPropertyDocument - { - return $this->isOrm() ? new DummyProperty() : new DummyPropertyDocument(); - } - - private function buildEmbeddableDummy(): EmbeddableDummy|EmbeddableDummyDocument - { - return $this->isOrm() ? new EmbeddableDummy() : new EmbeddableDummyDocument(); - } - - private function buildEmbeddedDummy(): EmbeddedDummy|EmbeddedDummyDocument - { - return $this->isOrm() ? new EmbeddedDummy() : new EmbeddedDummyDocument(); - } - - private function buildFileConfigDummy(): FileConfigDummy|FileConfigDummyDocument - { - return $this->isOrm() ? new FileConfigDummy() : new FileConfigDummyDocument(); - } - - private function buildFoo(): Foo|FooDocument - { - return $this->isOrm() ? new Foo() : new FooDocument(); - } - - private function buildFooDummy(): FooDummy|FooDummyDocument - { - return $this->isOrm() ? new FooDummy() : new FooDummyDocument(); - } - - private function buildFooEmbeddable(): FooEmbeddable|FooEmbeddableDocument - { - return $this->isOrm() ? new FooEmbeddable() : new FooEmbeddableDocument(); - } - - private function buildFourthLevel(): FourthLevel|FourthLevelDocument - { - return $this->isOrm() ? new FourthLevel() : new FourthLevelDocument(); - } - - private function buildGreeting(): Greeting|GreetingDocument - { - return $this->isOrm() ? new Greeting() : new GreetingDocument(); - } - - private function buildIriOnlyDummy(): IriOnlyDummy|IriOnlyDummyDocument - { - return $this->isOrm() ? new IriOnlyDummy() : new IriOnlyDummyDocument(); - } - - private function buildMaxDepthDummy(): MaxDepthDummy|MaxDepthDummyDocument - { - return $this->isOrm() ? new MaxDepthDummy() : new MaxDepthDummyDocument(); - } - - private function buildPerson(): Person|PersonDocument - { - return $this->isOrm() ? new Person() : new PersonDocument(); - } - - private function buildPersonToPet(): PersonToPet|PersonToPetDocument - { - return $this->isOrm() ? new PersonToPet() : new PersonToPetDocument(); - } - - private function buildPet(): Pet|PetDocument - { - return $this->isOrm() ? new Pet() : new PetDocument(); - } - - private function buildQuestion(): Question|QuestionDocument - { - return $this->isOrm() ? new Question() : new QuestionDocument(); - } - - private function buildRelatedDummy(): RelatedDummy|RelatedDummyDocument - { - return $this->isOrm() ? new RelatedDummy() : new RelatedDummyDocument(); - } - - private function buildRelatedOwnedDummy(): RelatedOwnedDummy|RelatedOwnedDummyDocument - { - return $this->isOrm() ? new RelatedOwnedDummy() : new RelatedOwnedDummyDocument(); - } - - private function buildRelatedOwningDummy(): RelatedOwningDummy|RelatedOwningDummyDocument - { - return $this->isOrm() ? new RelatedOwningDummy() : new RelatedOwningDummyDocument(); - } - - private function buildRelatedToDummyFriend(): RelatedToDummyFriend|RelatedToDummyFriendDocument - { - return $this->isOrm() ? new RelatedToDummyFriend() : new RelatedToDummyFriendDocument(); - } - - private function buildRelatedLinkedDummy(): RelatedLinkedDummy|RelatedLinkedDummyDocument - { - return $this->isOrm() ? new RelatedLinkedDummy() : new RelatedLinkedDummyDocument(); - } - - private function buildRelationEmbedder(): RelationEmbedder|RelationEmbedderDocument - { - return $this->isOrm() ? new RelationEmbedder() : new RelationEmbedderDocument(); - } - - private function buildSecuredDummy(): SecuredDummy|SecuredDummyDocument - { - return $this->isOrm() ? new SecuredDummy() : new SecuredDummyDocument(); - } - - private function buildRelatedSecureDummy(): RelatedSecuredDummy|RelatedSecuredDummyDocument - { - return $this->isOrm() ? new RelatedSecuredDummy() : new RelatedSecuredDummyDocument(); - } - - private function buildSoMany(): SoMany|SoManyDocument - { - return $this->isOrm() ? new SoMany() : new SoManyDocument(); - } - - private function buildThirdLevel(): ThirdLevel|ThirdLevelDocument - { - return $this->isOrm() ? new ThirdLevel() : new ThirdLevelDocument(); - } - - private function buildConvertedDate(): ConvertedDate|ConvertedDateDocument - { - return $this->isOrm() ? new ConvertedDate() : new ConvertedDateDocument(); - } - - private function buildConvertedBoolean(): ConvertedBoolean|ConvertedBoolDocument - { - return $this->isOrm() ? new ConvertedBoolean() : new ConvertedBoolDocument(); - } - - private function buildConvertedInteger(): ConvertedInteger|ConvertedIntegerDocument - { - return $this->isOrm() ? new ConvertedInteger() : new ConvertedIntegerDocument(); - } - - private function buildConvertedString(): ConvertedString|ConvertedStringDocument - { - return $this->isOrm() ? new ConvertedString() : new ConvertedStringDocument(); - } - - private function buildConvertedOwner(): ConvertedOwner|ConvertedOwnerDocument - { - return $this->isOrm() ? new ConvertedOwner() : new ConvertedOwnerDocument(); - } - - private function buildConvertedRelated(): ConvertedRelated|ConvertedRelatedDocument - { - return $this->isOrm() ? new ConvertedRelated() : new ConvertedRelatedDocument(); - } - - private function buildDummyMercure(): DummyMercure|DummyMercureDocument - { - return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); - } - - private function buildAbsoluteUrlDummy(): AbsoluteUrlDummyDocument|AbsoluteUrlDummy - { - return $this->isOrm() ? new AbsoluteUrlDummy() : new AbsoluteUrlDummyDocument(); - } - - private function buildAbsoluteUrlRelationDummy(): AbsoluteUrlRelationDummyDocument|AbsoluteUrlRelationDummy - { - return $this->isOrm() ? new AbsoluteUrlRelationDummy() : new AbsoluteUrlRelationDummyDocument(); - } - - private function buildNetworkPathDummy(): NetworkPathDummyDocument|NetworkPathDummy - { - return $this->isOrm() ? new NetworkPathDummy() : new NetworkPathDummyDocument(); - } - - private function buildNetworkPathRelationDummy(): NetworkPathRelationDummyDocument|NetworkPathRelationDummy - { - return $this->isOrm() ? new NetworkPathRelationDummy() : new NetworkPathRelationDummyDocument(); - } - - private function buildInitializeInput(): InitializeInput|InitializeInputDocument - { - return $this->isOrm() ? new InitializeInput() : new InitializeInputDocument(); - } - - private function buildPatchDummyRelation(): PatchDummyRelation|PatchDummyRelationDocument - { - return $this->isOrm() ? new PatchDummyRelation() : new PatchDummyRelationDocument(); - } - - private function buildBook(): BookDocument|Book - { - return $this->isOrm() ? new Book() : new BookDocument(); - } - - private function buildCustomMultipleIdentifierDummy(): CustomMultipleIdentifierDummy|CustomMultipleIdentifierDummyDocument - { - return $this->isOrm() ? new CustomMultipleIdentifierDummy() : new CustomMultipleIdentifierDummyDocument(); - } - - private function buildWithJsonDummy(): WithJsonDummy|WithJsonDummyDocument - { - return $this->isOrm() ? new WithJsonDummy() : new WithJsonDummyDocument(); - } - - private function buildPayment(string $amount): Payment|PaymentDocument - { - return $this->isOrm() ? new Payment($amount) : new PaymentDocument($amount); - } - - private function buildMultiRelationsDummy(): MultiRelationsDummy|MultiRelationsDummyDocument - { - return $this->isOrm() ? new MultiRelationsDummy() : new MultiRelationsDummyDocument(); - } - - private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|MultiRelationsRelatedDummyDocument - { - return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument(); - } - - private function buildMultiRelationsNested(): MultiRelationsNested|MultiRelationsNestedDocument - { - return $this->isOrm() ? new MultiRelationsNested() : new MultiRelationsNestedDocument(); - } - - private function buildMultiRelationsNestedPaginated(): MultiRelationsNestedPaginated|MultiRelationsNestedPaginatedDocument - { - return $this->isOrm() ? new MultiRelationsNestedPaginated() : new MultiRelationsNestedPaginatedDocument(); - } - - private function buildMultiRelationsResolveDummy(): MultiRelationsResolveDummy|MultiRelationsResolveDummyDocument - { - return $this->isOrm() ? new MultiRelationsResolveDummy() : new MultiRelationsResolveDummyDocument(); - } - - private function buildMusicGroup(): MusicGroup|MusicGroupDocument - { - return $this->isOrm() ? new MusicGroup() : new MusicGroupDocument(); - } - - private function buildVideoGame(): VideoGame|VideoGameDocument - { - return $this->isOrm() ? new VideoGame() : new VideoGameDocument(); - } - - private function buildSeparatedEntity(): SeparatedEntity|SeparatedEntityDocument - { - return $this->isOrm() ? new SeparatedEntity() : new SeparatedEntityDocument(); - } - - private function buildLinkHandledDummy(string $slug): LinkHandledDummy|LinkHandledDummyDocument - { - return $this->isOrm() ? new LinkHandledDummy($slug) : new LinkHandledDummyDocument($slug); - } -} diff --git a/tests/Behat/HttpCacheContext.php b/tests/Behat/HttpCacheContext.php deleted file mode 100644 index d06ba3414eb..00000000000 --- a/tests/Behat/HttpCacheContext.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorCustom; -use Behat\Behat\Context\Context; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behat\Mink\Driver\BrowserKitDriver; -use Behat\MinkExtension\Context\MinkContext; -use FriendsOfBehat\SymfonyExtension\Context\Environment\InitializedSymfonyExtensionEnvironment; -use PHPUnit\Framework\ExpectationFailedException; -use Symfony\Bundle\FrameworkBundle\KernelBrowser; -use Symfony\Component\DependencyInjection\ContainerInterface; - -/** - * @author Kévin Dunglas - */ -final class HttpCacheContext implements Context -{ - public function __construct(private ContainerInterface $driverContainer) - { - } - - /** - * @BeforeScenario @customTagCollector - */ - public function registerCustomTagCollector(BeforeScenarioScope $scope): void - { - $this->disableReboot($scope); - /** @phpstan-ignore-next-line */ - $iriConverter = $this->driverContainer->get('api_platform.iri_converter'); - $this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom($iriConverter)); - } - - /** - * @Then :iris IRIs should be purged - */ - public function irisShouldBePurged(string $iris): void - { - $purger = $this->driverContainer->get('test.api_platform.http_cache.purger'); - - $iris = explode(',', $iris); - sort($iris); - $iris = implode(',', $iris); - - $purgedIris = $purger->getIris(); - sort($purgedIris); - $purgedIris = implode(',', $purgedIris); - - $purger->clear(); - - if ($iris !== $purgedIris) { - throw new ExpectationFailedException(\sprintf('IRIs "%s" does not match expected "%s".', $purgedIris, $iris)); - } - } - - /** - * this is necessary to allow overriding services - * see https://github.com/FriendsOfBehat/SymfonyExtension/issues/149 for details. - */ - private function disableReboot(BeforeScenarioScope $scope): void - { - $env = $scope->getEnvironment(); - if (!$env instanceof InitializedSymfonyExtensionEnvironment) { - return; - } - - $driver = $env->getContext(MinkContext::class)->getSession()->getDriver(); - if (!$driver instanceof BrowserKitDriver) { - return; - } - - $client = $driver->getClient(); - if (!$client instanceof KernelBrowser) { - return; - } - - $client->disableReboot(); - } -} diff --git a/tests/Behat/HydraContext.php b/tests/Behat/HydraContext.php deleted file mode 100644 index a0425ac2b13..00000000000 --- a/tests/Behat/HydraContext.php +++ /dev/null @@ -1,326 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use PHPUnit\Framework\Assert; -use PHPUnit\Framework\ExpectationFailedException; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; - -final class HydraContext implements Context -{ - private ?RestContext $restContext = null; - - public function __construct(private readonly PropertyAccessorInterface $propertyAccessor) - { - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the Hydra class :class exists - */ - public function assertTheHydraClassExist(string $className): void - { - try { - $this->getClassInfo($className); - } catch (\InvalidArgumentException $e) { - throw new ExpectationFailedException(\sprintf('The class "%s" doesn\'t exist.', $className), null, $e); - } - } - - /** - * @Then the Hydra class :class doesn't exist - */ - public function assertTheHydraClassNotExist(string $className): void - { - try { - $this->getClassInfo($className); - } catch (\InvalidArgumentException) { - return; - } - - throw new ExpectationFailedException(\sprintf('The class "%s" exists.', $className)); - } - - /** - * @Then the boolean value of the node :node of the Hydra class :class is true - */ - public function assertBooleanNodeValueIs(string $nodeName, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getClassInfo($className), $nodeName)); - } - - /** - * @Then the value of the node :node of the Hydra class :class is :value - */ - public function assertNodeValueIs(string $nodeName, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getClassInfo($className), $nodeName), - $value - ); - } - - /** - * @Then the boolean value of the node :node of the property :prop of the Hydra class :class is true - */ - public function assertPropertyNodeValueIsTrue(string $nodeName, string $propertyName, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getPropertyInfo($propertyName, $className), $nodeName)); - } - - /** - * @Then the value of the node :node of the property :prop of the Hydra class :class is :value - */ - public function assertPropertyNodeValueIs(string $nodeName, string $propertyName, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getPropertyInfo($propertyName, $className), $nodeName), - $value - ); - } - - /** - * @Then the boolean value of the node :node of the operation :operation of the Hydra class :class is true - */ - public function assertOperationNodeBooleanValueIs(string $nodeName, string $operationMethod, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getOperation($operationMethod, $className), $nodeName)); - } - - /** - * @Then the value of the node :node of the operation :operation of the Hydra class :class is :value - */ - public function assertOperationNodeValueIs(string $nodeName, string $operationMethod, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getOperation($operationMethod, $className), $nodeName), - $value - ); - } - - /** - * @Then the value of the node :node of the operation :operation of the Hydra class :class contains :value - */ - public function assertOperationNodeValueContains(string $nodeName, string $operationMethod, string $className, string $value): void - { - $property = $this->getOperation($operationMethod, $className); - - Assert::assertContains($value, $this->propertyAccessor->getValue($property, $nodeName)); - } - - /** - * @Then :nb operations are available for Hydra class :class - */ - public function assertNbOperationsExist(int $nb, string $className): void - { - Assert::assertEquals($nb, \count($this->getOperations($className))); - } - - /** - * @Then :nb properties are available for Hydra class :class - */ - public function assertNbPropertiesExist(int $nb, string $className): void - { - Assert::assertEquals($nb, \count($this->getProperties($className))); - } - - /** - * @Then :prop property doesn't exist for the Hydra class :class - */ - public function assertPropertyNotExist(string $propertyName, string $className): void - { - try { - $this->getPropertyInfo($propertyName, $className); - } catch (\InvalidArgumentException) { - return; - } - - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" exists.', $propertyName, $className)); - } - - /** - * @Then :prop property is readable for Hydra class :class - */ - public function assertPropertyIsReadable(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:readable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not readable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is not readable for Hydra class :class - */ - public function assertPropertyIsNotReadable(string $propertyName, string $className): void - { - if ($this->getPropertyInfo($propertyName, $className)->{'hydra:readable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is readable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is writable for Hydra class :class - */ - public function assertPropertyIsWritable(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:writeable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not writable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is required for Hydra class :class - */ - public function assertPropertyIsRequired(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:required'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not required', $propertyName, $className)); - } - } - - /** - * @Then :prop property is not required for Hydra class :class - */ - public function assertPropertyIsNotRequired(string $propertyName, string $className): void - { - if ($this->getPropertyInfo($propertyName, $className)->{'hydra:required'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is required', $propertyName, $className)); - } - } - - /** - * Gets information about a property. - * - * @throws \InvalidArgumentException - */ - private function getPropertyInfo(string $propertyName, string $className): \stdClass - { - foreach ($this->getProperties($className) as $property) { - if ($property->{'hydra:title'} === $propertyName) { - return $property; - } - } - - throw new \InvalidArgumentException(\sprintf('Property "%s" of class "%s" doesn\'t exist', $propertyName, $className)); - } - - /** - * Gets an operation by its method name. - * - * @throws \InvalidArgumentException - */ - private function getOperation(string $method, string $className): \stdClass - { - foreach ($this->getOperations($className) as $operation) { - if ($operation->{'hydra:method'} === $method) { - return $operation; - } - } - - throw new \InvalidArgumentException(\sprintf('Operation "%s" of class "%s" doesn\'t exist.', $method, $className)); - } - - /** - * Gets all operations of a given class. - */ - private function getOperations(string $className): array - { - return $this->getClassInfo($className)->{'hydra:supportedOperation'} ?? []; - } - - /** - * Gets all properties of a given class. - */ - private function getProperties(string $className): array - { - return $this->getClassInfo($className)->{'hydra:supportedProperty'} ?? []; - } - - /** - * Gets information about a class. - * - * @throws \InvalidArgumentException - */ - private function getClassInfo(string $className): \stdClass - { - $json = $this->getLastJsonResponse(); - - if (isset($json->{'hydra:supportedClass'})) { - foreach ($json->{'hydra:supportedClass'} as $classData) { - if ($classData->{'hydra:title'} === $className) { - return $classData; - } - } - } - - throw new \InvalidArgumentException(\sprintf('Class %s cannot be found in the vocabulary', $className)); - } - - /** - * Gets the last JSON response. - * - * @throws \RuntimeException - */ - private function getLastJsonResponse(): \stdClass - { - if (null === $decoded = json_decode($this->restContext->getMink()->getSession()->getDriver()->getContent(), null, 512, \JSON_THROW_ON_ERROR)) { - throw new \RuntimeException('JSON response seems to be invalid'); - } - - return $decoded; - } - - /** - * @Then the Hydra context matches the online resource :url - */ - public function assertHydraContextIsCorrect(string $url): void - { - $opts = [ - 'http' => [ - 'method' => 'GET', - 'header' => "User-Agent: Mozilla/5.0\r\n", - ], - ]; - - $context = stream_context_create($opts); - $upstream = json_decode(file_get_contents($url, false, $context)); - $actual = $this->getLastJsonResponse(); - $local = $actual->{'@context'}[0]; - Assert::assertEquals( - $upstream, - $local - ); - } -} diff --git a/tests/Behat/JsonApiContext.php b/tests/Behat/JsonApiContext.php deleted file mode 100644 index 7cd50646c57..00000000000 --- a/tests/Behat/JsonApiContext.php +++ /dev/null @@ -1,209 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CircularReference as CircularReferenceDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CircularReference; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use Behatch\Json\Json; -use Behatch\Json\JsonInspector; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\Persistence\ManagerRegistry; -use Doctrine\Persistence\ObjectManager; -use JsonSchema\Validator; -use PHPUnit\Framework\ExpectationFailedException; - -final class JsonApiContext implements Context -{ - private ?RestContext $restContext = null; - private readonly Validator $validator; - private readonly JsonInspector $inspector; - private readonly string $jsonApiSchemaFile; - private readonly ObjectManager $manager; - - public function __construct(ManagerRegistry $doctrine, string $jsonApiSchemaFile) - { - if (!is_file($jsonApiSchemaFile)) { - throw new \InvalidArgumentException('The JSON API schema doesn\'t exist.'); - } - - $this->validator = new Validator(); - $this->inspector = new JsonInspector('javascript'); - $this->jsonApiSchemaFile = $jsonApiSchemaFile; - $this->manager = $doctrine->getManager(); - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the JSON should be valid according to the JSON API schema - */ - public function theJsonShouldBeValidAccordingToTheJsonApiSchema(): void - { - $json = $this->getJson()->getContent(); - $this->validator->validate($json, (object) ['$ref' => "file://{$this->jsonApiSchemaFile}"]); - - if (!$this->validator->isValid()) { - throw new ExpectationFailedException('The JSON is not valid according to the JSON API schema.'); - } - } - - /** - * @Then the JSON node :node should be an empty array - */ - public function theJsonNodeShouldBeAnEmptyArray(string $node): void - { - $actual = $this->getValueOfNode($node); - if (null !== $actual && [] !== $actual) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual, \JSON_THROW_ON_ERROR))); - } - } - - /** - * @Then the JSON node :node should be a number - */ - public function theJsonNodeShouldBeANumber(string $node): void - { - if (!is_numeric($actual = $this->getValueOfNode($node))) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual, \JSON_THROW_ON_ERROR))); - } - } - - /** - * @Then the JSON node :node should not be an empty string - */ - public function theJsonNodeShouldNotBeAnEmptyString(string $node): void - { - if ('' === $actual = $this->getValueOfNode($node)) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual))); - } - } - - /** - * @Then the JSON node :node should be sorted - * @Then the JSON should be sorted - */ - public function theJsonNodeShouldBeSorted(string $node = ''): void - { - $actual = (array) $this->getValueOfNode($node); - - $expected = $actual; - ksort($expected); - - if ($actual !== $expected) { - throw new ExpectationFailedException(\sprintf('The json node "%s" is not sorted by keys', $node)); - } - } - - /** - * @Given there is a RelatedDummy - */ - public function thereIsARelatedDummy(): void - { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy with no friends'); - - $this->manager->persist($relatedDummy); - $this->manager->flush(); - } - - /** - * @Given there is a DummyFriend - */ - public function thereIsADummyFriend(): void - { - $friend = $this->buildDummyFriend(); - $friend->setName('DummyFriend'); - - $this->manager->persist($friend); - $this->manager->flush(); - } - - /** - * @Given there is a CircularReference - */ - public function thereIsACircularReference(): void - { - $circularReference = $this->buildCircularReference(); - $circularReference->parent = $circularReference; - - $circularReferenceBis = $this->buildCircularReference(); - $circularReferenceBis->parent = $circularReference; - - $circularReference->children->add($circularReference); - $circularReference->children->add($circularReferenceBis); - - $this->manager->persist($circularReference); - $this->manager->persist($circularReferenceBis); - $this->manager->flush(); - } - - private function getValueOfNode(string $node) - { - return $this->inspector->evaluate($this->getJson(), $node); - } - - private function getJson(): Json - { - return new Json($this->getContent()); - } - - private function getContent(): string - { - return $this->restContext->getMink()->getSession()->getDriver()->getContent(); - } - - private function isOrm(): bool - { - return $this->manager instanceof EntityManagerInterface; - } - - private function buildCircularReference(): CircularReference|CircularReferenceDocument - { - return $this->isOrm() ? new CircularReference() : new CircularReferenceDocument(); - } - - private function buildDummyFriend(): DummyFriend|DummyFriendDocument - { - return $this->isOrm() ? new DummyFriend() : new DummyFriendDocument(); - } - - private function buildRelatedDummy(): RelatedDummy|RelatedDummyDocument - { - return $this->isOrm() ? new RelatedDummy() : new RelatedDummyDocument(); - } -} diff --git a/tests/Behat/JsonContext.php b/tests/Behat/JsonContext.php deleted file mode 100644 index 4450465fd08..00000000000 --- a/tests/Behat/JsonContext.php +++ /dev/null @@ -1,112 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Mink\Exception\ExpectationException; -use Behatch\Context\JsonContext as BaseJsonContext; -use Behatch\HttpCall\HttpCallResultPool; -use Behatch\Json\Json; -use PHPUnit\Framework\Assert; - -final class JsonContext extends BaseJsonContext -{ - public function __construct(HttpCallResultPool $httpCallResultPool) - { - parent::__construct($httpCallResultPool); - } - - /** - * @Then the JSON node :node should contain: - */ - public function theJsonNodeShouldContainContent(string $node, PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception $e) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver(), $e); - } - - $actualContent = $this->inspector->evaluate($actual, $node); - - if (!is_iterable($actualContent)) { - throw new ExpectationException(\sprintf("The JSON is equal to:\n%s", json_encode($actualContent, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT)), $this->getSession()->getDriver()); - } - - foreach ($actualContent as $itemContent) { - try { - $this->assertEquals($expected->getContent(), $itemContent, ' '); - } catch (ExpectationException) { - continue; - } - - return; - } - - throw new ExpectationException("The JSON node \"{$node}\" does not contain the expected content.", $this->getSession()->getDriver()); - } - - /** - * @Then the JSON node :node should be equal to: - */ - public function theJsonNodeShouldBeEqualToContent(string $node, PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception $e) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver(), $e); - } - - $actualContent = $this->inspector->evaluate($actual, $node); - - $this->assertEquals( - $expected->getContent(), - $actualContent, - \sprintf("The JSON node \"%s\" is equal to:\n%s", $node, json_encode($actualContent, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT)) - ); - } - - public function theJsonShouldBeEqualTo(PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver()); - } - - $this->assertEquals( - $expected->getContent(), - $actual->getContent(), - "The JSON is equal to:\n{$actual->encode()}" - ); - } - - /** - * @Then /^the JSON should be a superset of:$/ - */ - public function theJsonIsASupersetOf(PyStringNode $content): void - { - $array = json_decode($this->httpCallResultPool->getResult()->getValue(), true, 512, \JSON_THROW_ON_ERROR); - $subset = json_decode($content->getRaw(), true, 512, \JSON_THROW_ON_ERROR); - - method_exists(Assert::class, 'assertArraySubset') ? Assert::assertArraySubset($subset, $array) : ApiTestCase::assertArraySubset($subset, $array); - } -} diff --git a/tests/Behat/JsonHalContext.php b/tests/Behat/JsonHalContext.php deleted file mode 100644 index 91cff357660..00000000000 --- a/tests/Behat/JsonHalContext.php +++ /dev/null @@ -1,80 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use Behatch\Json\Json; -use JsonSchema\Validator; -use PHPUnit\Framework\ExpectationFailedException; - -final class JsonHalContext implements Context -{ - private ?RestContext $restContext = null; - private readonly Validator $validator; - private readonly string $schemaFile; - - public function __construct(string $schemaFile) - { - if (!is_file($schemaFile)) { - throw new \InvalidArgumentException('The JSON HAL schema doesn\'t exist.'); - } - - $this->validator = new Validator(); - $this->schemaFile = $schemaFile; - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the JSON should be valid according to the JSON HAL schema - */ - public function theJsonShouldBeValidAccordingToTheJsonHALSchema(): void - { - $json = $this->getJson()->getContent(); - $this->validator->validate($json, (object) ['$ref' => "file://{$this->schemaFile}"]); - - if (!$this->validator->isValid()) { - throw new ExpectationFailedException('The JSON is not valid according to the HAL+JSON schema.'); - } - } - - private function getJson(): Json - { - return new Json($this->getContent()); - } - - private function getContent(): string - { - return $this->restContext->getMink()->getSession()->getDriver()->getContent(); - } -} diff --git a/tests/Behat/MercureContext.php b/tests/Behat/MercureContext.php deleted file mode 100644 index 2dbd68f8775..00000000000 --- a/tests/Behat/MercureContext.php +++ /dev/null @@ -1,144 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Mercure\TestHub; -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use PHPUnit\Framework\Assert; -use Psr\Container\ContainerInterface; -use Symfony\Component\Mercure\Update; - -/** - * Context for Mercure. - * - * @author Alan Poulain - */ -final class MercureContext implements Context -{ - public function __construct(private readonly ContainerInterface $driverContainer) - { - } - - /** - * @Then :number Mercure updates should have been sent - * @Then :number Mercure update should have been sent - */ - public function mercureUpdatesShouldHaveBeenSent(int $number): void - { - $updateHandler = $this->getMercureTestHub(); - $total = \count($updateHandler->getUpdates()); - - if (0 === $total) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - Assert::assertEquals($number, $total, \sprintf('Expected %d Mercure updates to be sent, got %d.', $number, $total)); - } - - /** - * @Then the first Mercure update should have topics: - * @Then the Mercure update should have topics: - */ - public function firstMercureUpdateShouldHaveTopics(TableNode $table): void - { - $this->mercureUpdateShouldHaveTopics(1, $table); - } - - /** - * @Then the first Mercure update should have data: - * @Then the Mercure update should have data: - */ - public function firstMercureUpdateShouldHaveData(PyStringNode $data): void - { - $this->mercureUpdateShouldHaveData(1, $data); - } - - /** - * @Then the Mercure update number :index should have topics: - */ - public function mercureUpdateShouldHaveTopics(int $index, TableNode $table): void - { - $updateHandler = $this->getMercureTestHub(); - $updates = $updateHandler->getUpdates(); - - if (0 === \count($updates)) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - if (!isset($updates[$index - 1])) { - throw new \RuntimeException(\sprintf('Mercure update #%d does not exist.', $index)); - } - /** @var Update $update */ - $update = $updates[$index - 1]; - Assert::assertEquals(array_keys($table->getRowsHash()), array_values($update->getTopics())); - } - - /** - * @Then the Mercure update number :index should have data: - */ - public function mercureUpdateShouldHaveData(int $index, PyStringNode $data): void - { - $updateHandler = $this->getMercureTestHub(); - $updates = $updateHandler->getUpdates(); - - if (0 === \count($updates)) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - if (!isset($updates[$index - 1])) { - throw new \RuntimeException(\sprintf('Mercure update #%d does not exist.', $index)); - } - /** @var Update $update */ - $update = $updates[$index - 1]; - Assert::assertJsonStringEqualsJsonString($data->getRaw(), $update->getData()); - } - - /** - * @Then the following Mercure update with topics :topics should have been sent: - */ - public function theFollowingMercureUpdateShouldHaveBeenSent(string $topics, PyStringNode $update): void - { - $topics = explode(',', $topics); - $update = json_decode($update->getRaw(), true, 512, \JSON_THROW_ON_ERROR); - - $updateHandler = $this->getMercureTestHub(); - foreach ($updateHandler->getUpdates() as $sentUpdate) { - $toMatchTopics = \count($topics); - foreach ($sentUpdate->getTopics() as $sentTopic) { - foreach ($topics as $topic) { - if (preg_match("@$topic@", (string) $sentTopic)) { - --$toMatchTopics; - } - } - } - - if ($toMatchTopics > 0) { - continue; - } - - if ($sentUpdate->getData() === json_encode($update, \JSON_THROW_ON_ERROR)) { - return; - } - } - - throw new \RuntimeException('Mercure update has not been sent.'); - } - - private function getMercureTestHub(): TestHub - { - return $this->driverContainer->get('mercure.hub.default.test_hub'); - } -} diff --git a/tests/Behat/XmlContext.php b/tests/Behat/XmlContext.php deleted file mode 100644 index 33a811470d2..00000000000 --- a/tests/Behat/XmlContext.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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\Behat; - -use Behat\Gherkin\Node\PyStringNode; -use Behatch\Context\XmlContext as BaseXmlContext; -use Symfony\Component\Serializer\Encoder\XmlEncoder; - -final class XmlContext extends BaseXmlContext -{ - private readonly XmlEncoder $xmlEncoder; - - public function __construct() - { - $this->xmlEncoder = new XmlEncoder(); - } - - /** - * @Then the XML should be equal to: - */ - public function theXmlShouldBeEqualTo(PyStringNode $content): void - { - $expected = $this->xmlEncoder->decode((string) $content, 'xml'); - $actual = $this->xmlEncoder->decode($actualXml = $this->getSession()->getPage()->getContent(), 'xml'); - - $this->assertEquals( - $expected, - $actual, - "The XML is equal to:\n{$actualXml}" - ); - } -} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index e26c8bc3eb7..6cb1bb86951 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use ApiPlatform\Symfony\Bundle\ApiPlatformBundle; -use ApiPlatform\Tests\Behat\DoctrineContext; use ApiPlatform\Tests\Fixtures\TestBundle\Document\User as UserDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; @@ -27,7 +26,6 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\MongoDBBundle\Command\TailCursorDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; -use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle; use Symfony\AI\McpBundle\McpBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; @@ -63,7 +61,6 @@ public function __construct(string $environment, bool $debug, ?bool $genIdDefaul { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; $this->genIdDefault = $genIdDefault ?? $_SERVER['GEN_ID_DEFAULT'] ?? null; } @@ -81,10 +78,6 @@ public function registerBundles(): array new MakerBundle(), ]; - if (null === ($_ENV['APP_PHPUNIT'] ?? null) && class_exists(FriendsOfBehatSymfonyExtensionBundle::class)) { - $bundles[] = new FriendsOfBehatSymfonyExtensionBundle(); - } - if (extension_loaded('mongodb') && class_exists(DoctrineMongoDBBundle::class)) { $bundles[] = new DoctrineMongoDBBundle(); } @@ -120,11 +113,6 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $loader->load(__DIR__."/config/config_{$this->getEnvironment()}.yml"); - if (interface_exists(Behat\Behat\Context\Context::class) && class_exists(DoctrineContext::class)) { - $loader->load(__DIR__.('mongodb' === $this->getEnvironment() ? '/config/config_behat_mongodb.yml' : '/config/config_behat_orm.yml')); - $c->getDefinition(DoctrineContext::class)->setArgument('$passwordHasher', class_exists(NativePasswordHasher::class) ? 'security.user_password_encoder' : 'security.user_password_hasher'); - } - $messengerConfig = [ 'default_bus' => 'messenger.bus.default', 'buses' => [ diff --git a/tests/Fixtures/app/config/config_behat_mongodb.yml b/tests/Fixtures/app/config/config_behat_mongodb.yml deleted file mode 100644 index d5974d086ca..00000000000 --- a/tests/Fixtures/app/config/config_behat_mongodb.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - _defaults: - autowire: true - autoconfigure: true - - ApiPlatform\Tests\Behat\CommandContext: ~ - ApiPlatform\Tests\Behat\DoctrineContext: - $doctrine: '@doctrine_mongodb' - ApiPlatform\Tests\Behat\HttpCacheContext: ~ - ApiPlatform\Tests\Behat\HydraContext: ~ - ApiPlatform\Tests\Behat\JsonApiContext: - $doctrine: '@doctrine_mongodb' - $jsonApiSchemaFile: '%kernel.project_dir%/../JsonSchema/jsonapi.json' - ApiPlatform\Tests\Behat\JsonHalContext: - $schemaFile: '%kernel.project_dir%/../JsonHal/jsonhal.json' - ApiPlatform\Tests\Behat\MercureContext: - $driverContainer: '@behat.driver.service_container' diff --git a/tests/Fixtures/app/config/config_behat_orm.yml b/tests/Fixtures/app/config/config_behat_orm.yml deleted file mode 100644 index da76d75622e..00000000000 --- a/tests/Fixtures/app/config/config_behat_orm.yml +++ /dev/null @@ -1,18 +0,0 @@ -services: - _defaults: - public: false - autowire: true - autoconfigure: true - - ApiPlatform\Tests\Behat\CommandContext: ~ - ApiPlatform\Tests\Behat\DoctrineContext: - $doctrine: '@doctrine' - ApiPlatform\Tests\Behat\HttpCacheContext: ~ - ApiPlatform\Tests\Behat\HydraContext: ~ - ApiPlatform\Tests\Behat\JsonApiContext: - $doctrine: '@doctrine' - $jsonApiSchemaFile: '%kernel.project_dir%/../JsonSchema/jsonapi.json' - ApiPlatform\Tests\Behat\JsonHalContext: - $schemaFile: '%kernel.project_dir%/../JsonHal/jsonhal.json' - ApiPlatform\Tests\Behat\MercureContext: - $driverContainer: '@behat.driver.service_container' diff --git a/features/files/test.gif b/tests/Functional/GraphQl/Fixtures/test.gif similarity index 100% rename from features/files/test.gif rename to tests/Functional/GraphQl/Fixtures/test.gif diff --git a/tests/Functional/GraphQl/MutationTest.php b/tests/Functional/GraphQl/MutationTest.php index 21eb9329680..f1413d73812 100644 --- a/tests/Functional/GraphQl/MutationTest.php +++ b/tests/Functional/GraphQl/MutationTest.php @@ -55,7 +55,7 @@ final class MutationTest extends ApiTestCase protected static ?bool $alwaysBootKernel = false; - private const FIXTURES_DIR = __DIR__.'/../../../features/files'; + private const FIXTURES_DIR = __DIR__.'/Fixtures'; /** * @return class-string[] From 141beb0cd57c5458db1d5d848b591099d557ada7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 10:46:45 +0200 Subject: [PATCH 05/13] test: fix CI failures after behat migration Address CS, PHPStan, MongoDB and deprecation issues exposed by the behat-to-PHPUnit migration: - Run php-cs-fixer on migrated GraphQL test files - Use self:: (not static::) for private invalidateMetadataPools() in WithResourcesTrait to satisfy phpstan - Fix RecreateSchemaTrait::recreateSchema() Entity->Document rewrite to match the \Entity\ namespace segment only, preventing classes already in \Document\ (e.g. SeparatedEntity aliased as SeparatedDocument) from being rewritten to non-existent classes - Branch on isMongoDB() in MutationTest to instantiate the correct FooEmbeddable (Document vs Entity) - Expect the symfony/serializer 8.1 PartialDenormalizationException:: getErrors() deprecation in tests that trigger collect_denormalization_errors - Assign distinct shortNames to sub-collection ApiResource attributes on DummyProduct, DummyAggregateOffer, DummyOffer and Greeting to silence the 4.2 multiple-shortName deprecation; update SubResourceTest @context assertions accordingly - Widen $files PHPDoc on executeGraphQlMultipart to accept UploadedFile --- src/GraphQl/Test/GraphQlTestTrait.php | 6 +++--- tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php | 4 ++-- tests/Fixtures/TestBundle/Entity/DummyOffer.php | 6 +++--- tests/Fixtures/TestBundle/Entity/DummyProduct.php | 2 +- tests/Fixtures/TestBundle/Entity/Greeting.php | 2 +- tests/Functional/EnumDenormalizationValidationTest.php | 6 ++++++ tests/Functional/GraphQl/CollectionTest.php | 1 - tests/Functional/GraphQl/MutationTest.php | 6 ++++-- tests/Functional/GraphQl/SubscriptionTest.php | 2 +- tests/Functional/NullOnNonNullablePropertyTest.php | 6 ++++++ tests/Functional/SubResource/SubResourceTest.php | 6 +++--- tests/RecreateSchemaTrait.php | 2 +- tests/WithResourcesTrait.php | 4 ++-- 13 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/GraphQl/Test/GraphQlTestTrait.php b/src/GraphQl/Test/GraphQlTestTrait.php index 630e64b2f53..925cacdfa11 100644 --- a/src/GraphQl/Test/GraphQlTestTrait.php +++ b/src/GraphQl/Test/GraphQlTestTrait.php @@ -27,7 +27,7 @@ trait GraphQlTestTrait { /** - * @param array $variables + * @param array $variables * @param array $headers */ protected function executeGraphQl(string $query, array $variables = [], ?string $operationName = null, array $headers = []): ResponseInterface @@ -63,8 +63,8 @@ protected function introspectSchema(array $headers = []): ResponseInterface * Send a `multipart/form-data` GraphQL request following the * graphql-multipart-request-spec (https://github.com/jaydenseric/graphql-multipart-request-spec). * - * @param array $files Map of file marker => absolute file path - * @param array $headers + * @param array $files Map of file marker => absolute file path or UploadedFile + * @param array $headers */ protected function executeGraphQlMultipart(string $operations, string $map, array $files, array $headers = []): ResponseInterface { diff --git a/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php b/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php index 7089cdf2a70..09e22711d40 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php +++ b/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php @@ -28,8 +28,8 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_products/{id}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/offers{._format}', shortName: 'DummyAggregateOfferByProduct', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers{._format}', shortName: 'DummyAggregateOfferByRelatedProduct', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyAggregateOffer { diff --git a/tests/Fixtures/TestBundle/Entity/DummyOffer.php b/tests/Fixtures/TestBundle/Entity/DummyOffer.php index 47c9c42142d..2988a075348 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyOffer.php +++ b/tests/Fixtures/TestBundle/Entity/DummyOffer.php @@ -26,9 +26,9 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_aggregate_offers/{id}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/offers/{offers}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers/{offers}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_aggregate_offers/{id}/offers{._format}', shortName: 'DummyOfferByAggregate', uriVariables: ['id' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/offers/{offers}/offers{._format}', shortName: 'DummyOfferByProductOffer', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers/{offers}/offers{._format}', shortName: 'DummyOfferByRelatedProductOffer', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyOffer { diff --git a/tests/Fixtures/TestBundle/Entity/DummyProduct.php b/tests/Fixtures/TestBundle/Entity/DummyProduct.php index d6e428f8a02..2e83f1b06a9 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyProduct.php +++ b/tests/Fixtures/TestBundle/Entity/DummyProduct.php @@ -28,7 +28,7 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products{._format}', shortName: 'DummyProductRelatedProducts', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyProduct { diff --git a/tests/Fixtures/TestBundle/Entity/Greeting.php b/tests/Fixtures/TestBundle/Entity/Greeting.php index d74e8217b55..7cad16e25a7 100644 --- a/tests/Fixtures/TestBundle/Entity/Greeting.php +++ b/tests/Fixtures/TestBundle/Entity/Greeting.php @@ -19,7 +19,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource] -#[ApiResource(uriTemplate: '/people/{id}/sent_greetings{._format}', uriVariables: ['id' => new Link(fromClass: Person::class, identifiers: ['id'], toProperty: 'sender')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/people/{id}/sent_greetings{._format}', shortName: 'GreetingBySender', uriVariables: ['id' => new Link(fromClass: Person::class, identifiers: ['id'], toProperty: 'sender')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class Greeting { diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 8d340915433..dfac2e84cd2 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Composer\InstalledVersions; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * @see https://github.com/api-platform/core/issues/8183 @@ -65,8 +66,13 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void $this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.'); } + #[IgnoreDeprecations] public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { + if (method_exists(\Symfony\Component\Serializer\Exception\PartialDenormalizationException::class, 'getNotNormalizableValueErrors')) { + $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); + } + $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => 'unknown'], diff --git a/tests/Functional/GraphQl/CollectionTest.php b/tests/Functional/GraphQl/CollectionTest.php index f47fb861f76..e7931490ce1 100644 --- a/tests/Functional/GraphQl/CollectionTest.php +++ b/tests/Functional/GraphQl/CollectionTest.php @@ -15,7 +15,6 @@ use ApiPlatform\GraphQl\Test\GraphQlTestTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositePrimitiveItem as CompositePrimitiveItemDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; diff --git a/tests/Functional/GraphQl/MutationTest.php b/tests/Functional/GraphQl/MutationTest.php index f1413d73812..380640c897d 100644 --- a/tests/Functional/GraphQl/MutationTest.php +++ b/tests/Functional/GraphQl/MutationTest.php @@ -15,15 +15,16 @@ use ApiPlatform\GraphQl\Test\GraphQlTestTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6354\ActivityLog; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomMutation as DummyCustomMutationDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooEmbeddable as FooEmbeddableDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\WritableId as WritableIdDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6354\ActivityLog; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; @@ -826,7 +827,8 @@ private function seedFooDummyWithEmbeddable(): void $foo = new $fooClass(); $foo->setName('Hawsepipe'); - $embedded = new FooEmbeddable(); + $embeddedClass = $this->isMongoDB() ? FooEmbeddableDocument::class : FooEmbeddable::class; + $embedded = new $embeddedClass(); $embedded->setDummyName('embeddedHawsepipe'); $foo->setEmbeddedFoo($embedded); $foo->setDummy($dummy); diff --git a/tests/Functional/GraphQl/SubscriptionTest.php b/tests/Functional/GraphQl/SubscriptionTest.php index d653f7d6bc3..72a1f43920f 100644 --- a/tests/Functional/GraphQl/SubscriptionTest.php +++ b/tests/Functional/GraphQl/SubscriptionTest.php @@ -184,7 +184,7 @@ public function testReceiveMercureUpdatesAfterPut(): void /** * @param list<\Symfony\Component\Mercure\Update> $updates - * @param array $expectedPayload + * @param array $expectedPayload */ private function assertMercureUpdatePresent(array $updates, string $topicPattern, array $expectedPayload): void { diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php index d6aa24c078f..56858fdbdb5 100644 --- a/tests/Functional/NullOnNonNullablePropertyTest.php +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @see https://github.com/symfony/symfony/issues/64159 */ final class NullOnNonNullablePropertyTest extends ApiTestCase @@ -45,8 +46,13 @@ public function testNullOnNonNullablePropertyReturns400(): void $this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? ''); } + #[IgnoreDeprecations] public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void { + if (method_exists(\Symfony\Component\Serializer\Exception\PartialDenormalizationException::class, 'getNotNormalizableValueErrors')) { + $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); + } + $response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => null], diff --git a/tests/Functional/SubResource/SubResourceTest.php b/tests/Functional/SubResource/SubResourceTest.php index 45d79665fae..cfc7c2328cf 100644 --- a/tests/Functional/SubResource/SubResourceTest.php +++ b/tests/Functional/SubResource/SubResourceTest.php @@ -369,7 +369,7 @@ public function testGetOffersFromAggregateOffers(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/DummyOffer', + '@context' => '/contexts/DummyOfferByProductOffer', '@id' => '/dummy_products/2/offers/1/offers', '@type' => 'hydra:Collection', 'hydra:member' => [[ @@ -391,7 +391,7 @@ public function testGetOffersFromAggregateOffersDirect(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/DummyOffer', + '@context' => '/contexts/DummyOfferByAggregate', '@id' => '/dummy_aggregate_offers/1/offers', '@type' => 'hydra:Collection', 'hydra:member' => [[ @@ -448,7 +448,7 @@ public function testPersonSentGreetings(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/Greeting', + '@context' => '/contexts/GreetingBySender', '@id' => '/people/1/sent_greetings', '@type' => 'hydra:Collection', 'hydra:member' => [[ diff --git a/tests/RecreateSchemaTrait.php b/tests/RecreateSchemaTrait.php index a5f53cb9326..24511eec159 100644 --- a/tests/RecreateSchemaTrait.php +++ b/tests/RecreateSchemaTrait.php @@ -31,7 +31,7 @@ private function recreateSchema(array $classes = []): void $schemaManager = $manager->getSchemaManager(); $firstDocumentClass = null; foreach ($classes as $c) { - $class = str_contains($c, 'Entity') ? str_replace('Entity', 'Document', $c) : $c; + $class = str_contains($c, '\\Entity\\') ? str_replace('\\Entity\\', '\\Document\\', $c) : $c; $firstDocumentClass ??= $class; $schemaManager->dropDocumentCollection($class); } diff --git a/tests/WithResourcesTrait.php b/tests/WithResourcesTrait.php index 27394596115..aa8ee610b49 100644 --- a/tests/WithResourcesTrait.php +++ b/tests/WithResourcesTrait.php @@ -23,13 +23,13 @@ trait WithResourcesTrait protected static function writeResources(array $resources): void { file_put_contents(__DIR__.'/Fixtures/app/var/resources.php', \sprintf(' $v.'::class', $resources)))); - static::invalidateMetadataPools(); + self::invalidateMetadataPools(); } protected static function removeResources(): void { file_put_contents(__DIR__.'/Fixtures/app/var/resources.php', ' Date: Fri, 29 May 2026 11:00:54 +0200 Subject: [PATCH 06/13] test(mongodb): fix MappingTest and MutationTest - MappingTest::testShouldMapBetweenResourceAndEntity: pass MappedDocument on MongoDB. RecreateSchemaTrait's Entity->Document rewrite produces Document\MappedEntity which does not exist (target is MappedDocument) - MutationTest::testModifyNonWritablePropertyRejected: skip on MongoDB like the sibling testModifyNonWritableEmbeddedPropertyRejected; the scenario relies on the ORM-specific embeddable input behavior --- tests/Functional/GraphQl/MutationTest.php | 3 +++ tests/Functional/MappingTest.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Functional/GraphQl/MutationTest.php b/tests/Functional/GraphQl/MutationTest.php index 380640c897d..ac8bf520b09 100644 --- a/tests/Functional/GraphQl/MutationTest.php +++ b/tests/Functional/GraphQl/MutationTest.php @@ -365,6 +365,9 @@ public function testModifyItemWithEmbeddedObject(): void public function testModifyNonWritablePropertyRejected(): void { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } $this->recreateSchema([Dummy::class, FooDummy::class]); $this->seedFooDummyWithEmbeddable(); diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 353153b9f69..0b3571a0e9c 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -73,7 +73,7 @@ public function testShouldMapBetweenResourceAndEntity(): void $this->markTestSkipped('ObjectMapper not installed'); } - $this->recreateSchema([MappedEntity::class]); + $this->recreateSchema([$this->isMongoDB() ? MappedDocument::class : MappedEntity::class]); $this->loadFixtures(); $client = self::createClient(); $client->request('GET', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources'); From 88bb06975271a4eef33d3c2ea9f83b1bbe26edca Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 12:09:07 +0200 Subject: [PATCH 07/13] test: fix CI regressions - Add Document\RPCHandler messenger handler so MongoDB messenger RPC test has a matching handler (Entity\RPCHandler was the only one) - Use Composer\InstalledVersions::satisfies() instead of method_exists() to detect symfony/serializer >= 8.1 in deprecation expectations; PHPStan resolves method_exists() statically and flagged it as alreadyNarrowedType --- .../MessengerHandler/Document/RPCHandler.php | 25 +++++++++++++++++++ .../EnumDenormalizationValidationTest.php | 3 ++- .../NullOnNonNullablePropertyTest.php | 4 ++- 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php diff --git a/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php b/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php new file mode 100644 index 00000000000..d6edb96561b --- /dev/null +++ b/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php @@ -0,0 +1,25 @@ + + * + * 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\MessengerHandler\Document; + +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RPC; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +#[AsMessageHandler] +class RPCHandler +{ + public function __invoke(RPC $data): void + { + } +} diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index dfac2e84cd2..3fa939623c8 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Composer\InstalledVersions; +use Composer\Semver\VersionParser; use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @@ -69,7 +70,7 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void #[IgnoreDeprecations] public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { - if (method_exists(\Symfony\Component\Serializer\Exception\PartialDenormalizationException::class, 'getNotNormalizableValueErrors')) { + if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); } diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php index 56858fdbdb5..eba8ce3f10c 100644 --- a/tests/Functional/NullOnNonNullablePropertyTest.php +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -16,6 +16,8 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; use ApiPlatform\Tests\SetupClassResourcesTrait; +use Composer\InstalledVersions; +use Composer\Semver\VersionParser; use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @see https://github.com/symfony/symfony/issues/64159 */ @@ -49,7 +51,7 @@ public function testNullOnNonNullablePropertyReturns400(): void #[IgnoreDeprecations] public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void { - if (method_exists(\Symfony\Component\Serializer\Exception\PartialDenormalizationException::class, 'getNotNormalizableValueErrors')) { + if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); } From 8d3cdd3457553e77fed3d8b5bd513b2061b5ec82 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 14:43:19 +0200 Subject: [PATCH 08/13] chore: remove obsolete APP_PHPUNIT env var The conditional loading of phpunit.yml gated test resource subsetting behind APP_PHPUNIT. Now that behat is gone the kernel is only booted from phpunit (and from phpstan against the test container), so the config can be loaded unconditionally and the env var dropped. Ensure tests/Fixtures/app/var/resources.php exists before kernel boot so cache:clear works on fresh checkouts (e.g. opensearch CI, which runs cache:clear standalone without phpunit). Also harden TestSuiteConfigCache::getHash() against a missing file. --- phpunit.xml.dist | 1 - tests/Fixtures/app/AppKernel.php | 6 ++---- tests/Fixtures/app/bootstrap.php | 7 +++++++ tests/TestSuiteConfigCache.php | 4 +++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index aa1d500db50..0b224e1d83b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,7 +12,6 @@ - diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 6cb1bb86951..2b80573ea93 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -309,10 +309,8 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ $loader->load(__DIR__.'/config/config_swagger.php'); - // We reduce the amount of resources to the strict minimum to speed up tests - if (null !== ($_ENV['APP_PHPUNIT'] ?? null)) { - $loader->load(__DIR__.'/config/phpunit.yml'); - } + // Reduce the amount of resources to the strict minimum to speed up tests. + $loader->load(__DIR__.'/config/phpunit.yml'); if ('mongodb' === $this->environment) { $c->prependExtensionConfig('api_platform', [ diff --git a/tests/Fixtures/app/bootstrap.php b/tests/Fixtures/app/bootstrap.php index 10db0977595..cd8bdb858d1 100644 --- a/tests/Fixtures/app/bootstrap.php +++ b/tests/Fixtures/app/bootstrap.php @@ -23,4 +23,11 @@ require __DIR__.'/AppKernel.php'; require __DIR__.'/DefaultParametersAppKernel.php'; +if (!is_file($resourcesFile = __DIR__.'/var/resources.php')) { + if (!is_dir(\dirname($resourcesFile))) { + mkdir(\dirname($resourcesFile), 0777, true); + } + file_put_contents($resourcesFile, ' Date: Fri, 29 May 2026 16:42:44 +0200 Subject: [PATCH 09/13] cs --- tests/Fixtures/app/bootstrap.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Fixtures/app/bootstrap.php b/tests/Fixtures/app/bootstrap.php index cd8bdb858d1..d268c9f75e7 100644 --- a/tests/Fixtures/app/bootstrap.php +++ b/tests/Fixtures/app/bootstrap.php @@ -24,8 +24,8 @@ require __DIR__.'/DefaultParametersAppKernel.php'; if (!is_file($resourcesFile = __DIR__.'/var/resources.php')) { - if (!is_dir(\dirname($resourcesFile))) { - mkdir(\dirname($resourcesFile), 0777, true); + if (!is_dir(dirname($resourcesFile))) { + mkdir(dirname($resourcesFile), 0777, true); } file_put_contents($resourcesFile, ' Date: Fri, 29 May 2026 16:51:37 +0200 Subject: [PATCH 10/13] test: fall back to default factory when resources.php is empty After commit 8d3cdd345 phpunit.yml loads unconditionally, so the PhpUnitResourceNameCollectionFactory now decorates the cached factory even for console commands run standalone (e.g. the OpenAPI export CI step that runs cache:clear + api:openapi:export without phpunit). On a fresh checkout resources.php is empty, which produced an empty `paths` in the exported OpenAPI document and failed vacuum lint. Make PhpUnitResourceNameCollectionFactory accept its decoratee via .inner and fall back to the default discovery when no resources are configured. Phpunit suites still get the subsetting because they write resources.php before booting the kernel. --- tests/Fixtures/app/config/phpunit.yml | 1 + tests/PhpUnitResourceNameCollectionFactory.php | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Fixtures/app/config/phpunit.yml b/tests/Fixtures/app/config/phpunit.yml index 08ea3483740..25397178a81 100644 --- a/tests/Fixtures/app/config/phpunit.yml +++ b/tests/Fixtures/app/config/phpunit.yml @@ -7,5 +7,6 @@ services: arguments: $env: '%kernel.environment%' $classes: '%env(require:RESOURCES)%' + $decorated: '@.inner' config_cache_factory: class: ApiPlatform\Tests\ConfigCacheFactory diff --git a/tests/PhpUnitResourceNameCollectionFactory.php b/tests/PhpUnitResourceNameCollectionFactory.php index 8f65b82d7b6..dd5b5403f87 100644 --- a/tests/PhpUnitResourceNameCollectionFactory.php +++ b/tests/PhpUnitResourceNameCollectionFactory.php @@ -26,12 +26,16 @@ final class PhpUnitResourceNameCollectionFactory implements ResourceNameCollecti /** * @param class-string[] $classes */ - public function __construct(private readonly string $env, private readonly array $classes) + public function __construct(private readonly string $env, private readonly array $classes, private readonly ResourceNameCollectionFactoryInterface $decorated) { } public function create(): ResourceNameCollection { + if ([] === $this->classes) { + return $this->decorated->create(); + } + /* @var array */ $classes = []; foreach ($this->classes as $c) { From af446971417f56caa395d1811d6881218873475e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 16:55:13 +0200 Subject: [PATCH 11/13] test(mongodb): register Document\RPCHandler for messenger dispatch OverriddenOperationTest::testRpcMessengerOperationReturns202 was added in e6922f1d7 with only an Entity-based messenger handler in config_common.yml. In the mongodb env PhpUnitResourceNameCollectionFactory swaps Entity\RPC for Document\RPC, so the dispatched message had no handler and the endpoint returned 500. Mirror the messenger_with_inputs override pattern and register the Document handler under the same service id. --- tests/Fixtures/app/config/config_mongodb.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index ce611417a73..d0cec389caa 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -127,6 +127,12 @@ services: tags: - name: 'messenger.message_handler' + app.messenger_handler.rpc: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\MessengerHandler\Document\RPCHandler' + public: false + tags: + - name: 'messenger.message_handler' + ApiPlatform\Tests\Fixtures\TestBundle\State\DummyDtoInputOutputProvider: arguments: $decorated: '@ApiPlatform\Doctrine\Odm\State\ItemProvider' From f76e6c1e235d64b3226b6c268a85f18ac2dcff1b Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 17:08:19 +0200 Subject: [PATCH 12/13] test: add unique shortName to subresource fixtures Second #[ApiResource] attribute on AbsoluteUrlDummy, NetworkPathDummy and DummyResourceWithComplexConstructor shared the default shortName with the primary resource, triggering the 4.2 duplicate shortName deprecation. Give each subresource a distinct shortName to silence the warnings without opting into `deduplicate_resource_short_names`. --- tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php | 2 +- tests/Fixtures/TestBundle/Document/NetworkPathDummy.php | 2 +- tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php | 2 +- .../TestBundle/Entity/DummyResourceWithComplexConstructor.php | 1 + tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php index 62a18c4ab6c..9e9e6b893f6 100644 --- a/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php +++ b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php @@ -20,7 +20,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::ABS_URL)] -#[ApiResource(uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] +#[ApiResource(shortName: 'AbsoluteUrlDummySubresource', uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] #[ODM\Document] class AbsoluteUrlDummy { diff --git a/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php index 84bc5353cc5..9b8e8736943 100644 --- a/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php +++ b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php @@ -20,7 +20,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::NET_PATH)] -#[ApiResource(uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] +#[ApiResource(shortName: 'NetworkPathDummySubresource', uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] #[ODM\Document] class NetworkPathDummy { diff --git a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php index a632d81dc83..4eeb455e2d7 100644 --- a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php +++ b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php @@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::ABS_URL)] -#[ApiResource(uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] +#[ApiResource(shortName: 'AbsoluteUrlDummySubresource', uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] #[ORM\Entity] class AbsoluteUrlDummy { diff --git a/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php b/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php index 224206c15bf..2a850595ec3 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php +++ b/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php @@ -19,6 +19,7 @@ #[Post] #[ApiResource( + shortName: 'DummyResourceWithComplexConstructorByCompany', uriTemplate: '/companies/{companyId}/employees/{id}', uriVariables: [ 'companyId' => ['from_class' => Company::class, 'to_property' => 'company'], diff --git a/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php index 098750dc0dc..1ac8ec4d55b 100644 --- a/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php +++ b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php @@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::NET_PATH)] -#[ApiResource(uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] +#[ApiResource(shortName: 'NetworkPathDummySubresource', uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] #[ORM\Entity] class NetworkPathDummy { From eb50d7de07c629c3260a7f856f08b318ab6fdd2e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 17:23:59 +0200 Subject: [PATCH 13/13] test: gate phpunit.yml load behind APP_PHPUNIT env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 8d3cdd345 dropped the APP_PHPUNIT gate on the wrong premise that only phpunit boots the kernel. CI also boots the kernel standalone via tests/Fixtures/app/console for lint:container, cache:clear, redocly-lint and the OpenAPI export. Without the gate phpunit.yml loaded in those runs too, wiring PhpUnitResourceNameCollectionFactory against an empty resources.php and producing an OpenAPI document with no paths (vacuum lint fail). Restore the env-gated load: phpunit.xml.dist sets APP_PHPUNIT=true so phpunit runs still get the subsetted resource list, while standalone console boots fall back to the default discovery chain and see every resource. Revert the [] === classes fallback added in 4c8cbe6e5 along with its \$decorated constructor argument — the factory is no longer wired outside phpunit so the band-aid is unnecessary. --- phpunit.xml.dist | 1 + tests/Fixtures/app/AppKernel.php | 6 ++++-- tests/Fixtures/app/config/phpunit.yml | 1 - tests/PhpUnitResourceNameCollectionFactory.php | 6 +----- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0b224e1d83b..aa1d500db50 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,7 @@ + diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 2b80573ea93..6cb1bb86951 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -309,8 +309,10 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ $loader->load(__DIR__.'/config/config_swagger.php'); - // Reduce the amount of resources to the strict minimum to speed up tests. - $loader->load(__DIR__.'/config/phpunit.yml'); + // We reduce the amount of resources to the strict minimum to speed up tests + if (null !== ($_ENV['APP_PHPUNIT'] ?? null)) { + $loader->load(__DIR__.'/config/phpunit.yml'); + } if ('mongodb' === $this->environment) { $c->prependExtensionConfig('api_platform', [ diff --git a/tests/Fixtures/app/config/phpunit.yml b/tests/Fixtures/app/config/phpunit.yml index 25397178a81..08ea3483740 100644 --- a/tests/Fixtures/app/config/phpunit.yml +++ b/tests/Fixtures/app/config/phpunit.yml @@ -7,6 +7,5 @@ services: arguments: $env: '%kernel.environment%' $classes: '%env(require:RESOURCES)%' - $decorated: '@.inner' config_cache_factory: class: ApiPlatform\Tests\ConfigCacheFactory diff --git a/tests/PhpUnitResourceNameCollectionFactory.php b/tests/PhpUnitResourceNameCollectionFactory.php index dd5b5403f87..8f65b82d7b6 100644 --- a/tests/PhpUnitResourceNameCollectionFactory.php +++ b/tests/PhpUnitResourceNameCollectionFactory.php @@ -26,16 +26,12 @@ final class PhpUnitResourceNameCollectionFactory implements ResourceNameCollecti /** * @param class-string[] $classes */ - public function __construct(private readonly string $env, private readonly array $classes, private readonly ResourceNameCollectionFactoryInterface $decorated) + public function __construct(private readonly string $env, private readonly array $classes) { } public function create(): ResourceNameCollection { - if ([] === $this->classes) { - return $this->decorated->create(); - } - /* @var array */ $classes = []; foreach ($this->classes as $c) {