diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature deleted file mode 100644 index 5119643e553..00000000000 --- a/features/filter/filter_validation.feature +++ /dev/null @@ -1,109 +0,0 @@ -Feature: Validate filters based upon filter description - - Background: - Given I add "Accept" header equal to "application/json" - - @createSchema - Scenario: Required filter should not throw an error if set - When I am on "/filter_validators?required=foo&required-allow-empty=&arrayRequired[foo]=" - Then the response status code should be 200 - - Scenario: Required filter that does not allow empty value should throw an error if empty - When I am on "/filter_validators?required=&required-allow-empty=&arrayRequired[foo]=" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'required: This value should not be blank.' - - Scenario: Required filter should throw an error if not set - When I am on "/filter_validators" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'required: This value should not be blank.\nrequired-allow-empty: This value should not be null.' - - Scenario: Required filter should not throw an error if set - When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo" - Then the response status code should be 200 - - Scenario: Required filter should throw an error if not set - When I am on "/array_filter_validators" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'arrayRequired[]: This value should not be blank.\nindexedArrayRequired[foo]: This value should not be blank.' - - When I am on "/array_filter_validators?arrayRequired[foo]=foo" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'indexedArrayRequired[foo]: This value should not be blank.' - - When I am on "/array_filter_validators?arrayRequired[]=foo" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'indexedArrayRequired[foo]: This value should not be blank.' - - Scenario: Test filter bounds: maximum - When I am on "/filter_validators?required=foo&required-allow-empty&maximum=10" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&maximum=11" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'maximum: This value should be less than or equal to 10.' - - Scenario: Test filter bounds: exclusiveMaximum - When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=9" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=10" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'exclusiveMaximum: This value should be less than 10.' - - Scenario: Test filter bounds: minimum - When I am on "/filter_validators?required=foo&required-allow-empty&minimum=5" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&minimum=0" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'minimum: This value should be greater than or equal to 5.' - - Scenario: Test filter bounds: exclusiveMinimum - When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=6" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=5" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'exclusiveMinimum: This value should be greater than 5.' - - Scenario: Test filter bounds: max length - When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=123" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=1234" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'max-length-3: This value is too long. It should have 3 characters or less.' - - Scenario: Test filter bounds: min length - When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=123" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=12" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'min-length-3: This value is too short. It should have 3 characters or more.' - - Scenario: Test filter pattern - When I am on "/filter_validators?required=foo&required-allow-empty&pattern=pattern" - When I am on "/filter_validators?required=foo&required-allow-empty&pattern=nrettap" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&pattern=not-pattern" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'pattern: This value is not valid.' - - Scenario: Test filter enum - When I am on "/filter_validators?required=foo&required-allow-empty&enum=in-enum" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&enum=not-in-enum" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'enum: The value you selected is not a valid choice.' - - Scenario: Test filter multipleOf - When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=4" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=3" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'multiple-of: This value should be a multiple of 2.' diff --git a/features/filter/property_filter.feature b/features/filter/property_filter.feature deleted file mode 100644 index 3b225793d3f..00000000000 --- a/features/filter/property_filter.feature +++ /dev/null @@ -1,28 +0,0 @@ -Feature: Set properties to include - In order to select specific properties from a resource - As a client software developer - I need to select attributes to retrieve - - @createSchema - Scenario: Test properties filter - Given there are 1 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/dummies/1?properties[]=name&properties[]=alias&properties[]=relatedDummy&properties[]=name_converted" - Then the JSON node "name" should be equal to "Dummy #1" - And the JSON node "alias" should be equal to "Alias #0" - And the JSON node "relatedDummies" should not exist - And the JSON node "name_converted" should exist - - Scenario: Test relation embedding - When I send a "GET" request to "/dummies/1?properties[]=name&properties[]=alias&properties[relatedDummy][]=name" - Then the JSON node "name" should be equal to "Dummy #1" - And the JSON node "alias" should be equal to "Alias #0" - And the JSON node "relatedDummy.name" should be equal to "RelatedDummy #1" - And the JSON node "relatedDummies" should not exist - - Scenario: Test property filter on not resource relations - When I send a "GET" request to "/dummy-with-array-of-objects/1?properties[notResourceObject][]=foo&properties[arrayOfNotResourceObjects][]=bar" - Then the JSON node "notResourceObject.foo" should be equal to "foo" - And the JSON node "notResourceObject.bar" should not exist - And the JSON node "arrayOfNotResourceObjects[0].foo" should not exist - And the JSON node "arrayOfNotResourceObjects[0].bar" should be equal to "bar" - And the JSON node "id" should not exist diff --git a/features/http_cache/headers.feature b/features/http_cache/headers.feature deleted file mode 100644 index 7c000f79b05..00000000000 --- a/features/http_cache/headers.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Default values of HTTP cache headers - In order to make API responses cacheable - As an API software developer - I need to be able to set default cache headers values - - @createSchema - Scenario: Cache headers default value - When I send a "GET" request to "/relation_embedders" - Then the response status code should be 200 - And the header "Etag" should be equal to '"032297ac74d75a50"' - And the header "Cache-Control" should be equal to "max-age=60, public, s-maxage=3600" - And the header "Vary" should be equal to "Accept, Cookie, Accept-Language" diff --git a/features/http_cache/tag_collector_service.feature b/features/http_cache/tag_collector_service.feature deleted file mode 100644 index ed994aadb7e..00000000000 --- a/features/http_cache/tag_collector_service.feature +++ /dev/null @@ -1,268 +0,0 @@ -@sqlite -@customTagCollector -@disableForSymfonyLowest -Feature: Cache invalidation through HTTP Cache tags (custom TagCollector service) - In order to have a fast API - As an API software developer - I need to store API responses in a cache - - @createSchema - Scenario: Create a dummy resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - - Scenario: TagCollector can identify $object (IRI is overridden with custom logic) - When I send a "GET" request to "/relation_embedders/1" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/RE/1#anotherRelated,/RE/1#related,/RE/1" - - Scenario: Create some embedded resources - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "name": "Related" - } - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - - Scenario: TagCollector can add cache tags for relations (JSON-LD format) - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/relation_embedders/2" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/related_dummies/1#thirdLevel,/related_dummies/1,/RE/2#anotherRelated,/RE/2#related,/RE/2" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/2", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "symfony", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: TagCollector can add cache tags for relations (HAL format) - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/relation_embedders/2" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/RE/2,/related_dummies/1,/related_dummies/1#thirdLevel,/RE/2#anotherRelated,/RE/2#related" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/relation_embedders/2" - }, - "anotherRelated": { - "href": "/related_dummies/1" - } - }, - "_embedded": { - "anotherRelated": { - "_links": { - "self": { - "href": "/related_dummies/1" - } - }, - "symfony": "symfony" - } - }, - "krondstadt": "Krondstadt" - } - """ - - Scenario: TagCollector can add cache tags for relations (JSONAPI format) - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/relation_embedders/2" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/RE/2,/RE/2#anotherRelated,/RE/2#related" - And the JSON should be equal to: - """ - { - "data": { - "id": "/relation_embedders/2", - "type": "RelationEmbedder", - "attributes": { - "krondstadt": "Krondstadt" - }, - "relationships": { - "anotherRelated": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - }, - "related": { - "data": [] - } - } - } - } - """ - - Scenario: Create resource with extraProperties on ApiProperty - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/extra_properties_on_properties" with body: - """ - { - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - - Scenario: TagCollector can read propertyMetadata (tag is overridden with data from extraProperties) - When I send a "GET" request to "/extra_properties_on_properties/1" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/extra_properties_on_properties/1#overrideRelationTag,/extra_properties_on_properties/1" - - Scenario: Create two Relation2 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation2s" with body: - """ - { - } - """ - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation2s" with body: - """ - { - } - """ - Then the response status code should be 201 - - Scenario: Create a Relation3 with many to many - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation3s" with body: - """ - { - "relation2s": ["/relation2s/1", "/relation2s/2"] - } - """ - Then the response status code should be 201 - - Scenario: Get a Relation3 (test collection of links; JSON-LD format) - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/relation3s" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation3s/1#relation2s,/relation3s/1,/relation3s" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Relation3", - "@id": "/relation3s", - "@type": "hydra:Collection", - "hydra:totalItems": 1, - "hydra:member": [ - { - "@id": "/relation3s/1", - "@type": "Relation3", - "id": 1, - "relation2s": [ - "/relation2s/1", - "/relation2s/2" - ] - } - ] - } - """ - - Scenario: Get a Relation3 (test collection of links; HAL format) - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/relation3s" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation3s/1,/relation3s/1#relation2s,/relation3s" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/relation3s" - }, - "item": [ - { - "href": "/relation3s/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/relation3s/1" - }, - "relation2s": [ - { - "href": "/relation2s/1" - }, - { - "href": "/relation2s/2" - } - ] - }, - "id": 1 - } - ] - } - } - """ - - Scenario: Get a Relation3 (test collection of links; HAL format) - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/relation3s" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation3s/1,/relation3s/1#relation2s,/relation3s" - And the JSON should be equal to: - """ - { - "links": { - "self": "/relation3s" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "/relation3s/1", - "type": "Relation3", - "attributes": { - "_id": 1 - }, - "relationships": { - "relation2s": { - "data": [ - { - "type": "Relation2", - "id": "/relation2s/1" - }, - { - "type": "Relation2", - "id": "/relation2s/2" - } - ] - } - } - } - ] - } - """ diff --git a/features/http_cache/tags.feature b/features/http_cache/tags.feature deleted file mode 100644 index bcc5ed9370c..00000000000 --- a/features/http_cache/tags.feature +++ /dev/null @@ -1,142 +0,0 @@ -@sqlite -Feature: Cache invalidation through HTTP Cache tags - In order to have a fast API - As an API software developer - I need to store API responses in a cache - - @createSchema - Scenario: Create some embedded resources - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "name": "Related", - "thirdLevel": {} - } - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - And "/relation_embedders,/related_dummies,/third_levels" IRIs should be purged - - Scenario: Tags must be set for items - When I send a "GET" request to "/relation_embedders/1" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/third_levels/1,/related_dummies/1,/relation_embedders/1" - - Scenario: Create some more resources - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "name": "Another Related", - "thirdLevel": {} - } - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - - Scenario: Tags must be set for collections - When I send a "GET" request to "/relation_embedders" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/third_levels/1,/related_dummies/1,/relation_embedders/1,/third_levels/2,/related_dummies/2,/relation_embedders/2,/relation_embedders" - - Scenario: Purge item on update - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation_embedders/1" with body: - """ - { - "paris": "France" - } - """ - Then the response status code should be 200 - And the header "Cache-Tags" should not exist - And "/relation_embedders,/relation_embedders/1,/related_dummies/1" IRIs should be purged - - Scenario: Purge item and the related collection on update - When I add "Content-Type" header equal to "application/ld+json" - And I send a "DELETE" request to "/relation_embedders/1" - Then the response status code should be 204 - And the header "Cache-Tags" should not exist - And "/relation_embedders,/relation_embedders/1,/related_dummies/1" IRIs should be purged - - Scenario: Create two Relation2 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation2s" with body: - """ - { - } - """ - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation2s" with body: - """ - { - } - """ - Then the response status code should be 201 - - Scenario: Embedded collection must be listed in cache tags - When I send a "GET" request to "/relation2s/1" - Then the header "Cache-Tags" should be equal to "/relation2s/1" - - Scenario: Create a Relation1 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation1s" with body: - """ - { - "relation2": "/relation2s/1" - } - """ - Then the response status code should be 201 - And "/relation1s,/relation2s/1" IRIs should be purged - - Scenario: Update a Relation1 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation1s/1" with body: - """ - { - "relation2": "/relation2s/2" - } - """ - Then the response status code should be 200 - And "/relation1s,/relation1s/1,/relation2s/2,/relation2s/1" IRIs should be purged - - Scenario: Create a Relation3 with many to many - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation3s" with body: - """ - { - "relation2s": ["/relation2s/1", "/relation2s/2"] - } - """ - Then the response status code should be 201 - And "/relation3s,/relation2s/1,/relation2s/2" IRIs should be purged - - Scenario: Get a Relation3 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/relation3s" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation2s/1,/relation2s/2,/relation3s/1,/relation3s" - - Scenario: Update a collection member only (legacy non-standard PUT) - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation3s/1" with body: - """ - { - "relation2s": ["/relation2s/2"] - } - """ - Then the response status code should be 200 - And the header "Cache-Tags" should not exist - And "/relation3s,/relation3s/1,/relation2s/2,/relation2s,/relation2s/1" IRIs should be purged - - Scenario: Delete the collection owner - When I add "Content-Type" header equal to "application/ld+json" - And I send a "DELETE" request to "/relation3s/1" - Then the response status code should be 204 - And the header "Cache-Tags" should not exist - And "/relation3s,/relation3s/1,/relation2s/2" IRIs should be purged - diff --git a/features/issues/5926.feature b/features/issues/5926.feature deleted file mode 100644 index 640d8a1bc6c..00000000000 --- a/features/issues/5926.feature +++ /dev/null @@ -1,36 +0,0 @@ -Feature: Issue 5926 - In order to reproduce the issue at https://github.com/api-platform/core/issues/5926 - As a client software developer - I need to be able to use every operation on a resource with non-resources embed objects - - @!mongodb - Scenario: Create and retrieve a WriteResource - When I add "Accept" header equal to "application/json" - And I send a "GET" request to "/test_issue5926s/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/json; charset=utf-8" - - @!mongodb - Scenario: Create and retrieve a JSON:API WriteResource - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/test_issue5926s/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/vnd.api+json; charset=utf-8" - - @!mongodb - Scenario: Create and retrieve a LD+JSON WriteResource - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/test_issue5926s/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 - Scenario: Create and retrieve a HAL WriteResource - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/test_issue5926s/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/hal+json; charset=utf-8" diff --git a/features/json/input_output.feature b/features/json/input_output.feature deleted file mode 100644 index 2a5a1143162..00000000000 --- a/features/json/input_output.feature +++ /dev/null @@ -1,40 +0,0 @@ -Feature: JSON DTO input and output - In order to use the API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - - Background: - Given I add "Accept" header equal to "application/json" - And I add "Content-Type" header equal to "application/json" - - @createSchema - Scenario: Request a password reset - And I send a "POST" request to "/users_reset/password_reset_request" with body: - """ - { - "email": "user@example.com" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json; charset=utf-8" - And the JSON should be equal to: - """ - { - "emailSentAt": "2019-07-05T15:44:00+00:00" - } - """ - - @createSchema - Scenario: Request a password reset for a non-existent user - And I send a "POST" request to "/users_reset/password_reset_request" with body: - """ - { - "email": "does-not-exist@example.com" - } - """ - Then the response status code should be 404 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to "User does not exist." - diff --git a/features/json/relation.feature b/features/json/relation.feature deleted file mode 100644 index 3455d9d4123..00000000000 --- a/features/json/relation.feature +++ /dev/null @@ -1,229 +0,0 @@ -Feature: JSON relations support - In order to use a hypermedia API - As a client software developer - I need to be able to update relations between resources - - @createSchema - Scenario: Create a third level - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/third_levels" with body: - """ - { - "level": 3 - } - """ - Then the response status code should be 201 - 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 equal to: - """ - { - "@context": "/contexts/ThirdLevel", - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": null, - "badFourthLevel": null, - "id": 1, - "level": 3, - "test": true, - "relatedDummies": [] - } - """ - - Scenario: Create a new relation - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "symfony": "laravel" - } - } - """ - Then the response status code should be 201 - 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 equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "laravel", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Update the relation with a new one - When I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "/relation_embedders/1" with body: - """ - { - "anotherRelated": { - "symfony": "laravel2" - } - } - """ - 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 equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/2", - "@type": "https://schema.org/Product", - "symfony": "laravel2", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Update an embedded relation using an IRI - When I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "/relation_embedders/1" with body: - """ - { - "anotherRelated": { - "id": "/related_dummies/1", - "symfony": "API Platform" - } - } - """ - 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 equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "API Platform", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Update an embedded relation - When I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "/relation_embedders/1" with body: - """ - { - "anotherRelated": { - "id": 1, - "symfony": "API Platform 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 equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "API Platform 2", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Create a related dummy with a relation using plain identifiers - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/related_dummies" with body: - """ - { - "thirdLevel": "1" - } - """ - Then the response status code should be 201 - 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 equal to: - """ - { - "@context": "/contexts/RelatedDummy", - "@id": "/related_dummies/3", - "@type": "https://schema.org/Product", - "id": 3, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": null - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - """ - - Scenario: Passing a (valid) plain identifier on a relation - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/dummies" with body: - """ - { - "relatedDummy": "1", - "relatedDummies": [ - "1" - ], - "name": "Dummy with plain relations" - } - """ - Then the response status code should be 201 - 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 equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": "/related_dummies/1", - "relatedDummies": [ - "/related_dummies/1" - ], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "Dummy with plain relations", - "alias": null, - "foo": null - } - """ diff --git a/features/mercure/discover.feature b/features/mercure/discover.feature deleted file mode 100644 index fecc057506f..00000000000 --- a/features/mercure/discover.feature +++ /dev/null @@ -1,13 +0,0 @@ -Feature: Mercure discovery support - In order to let the client discovering the Mercure hub - As a client software developer - I need to retrieve the hub URL through a Link HTTP header - - @createSchema - Scenario: Checks that the Mercure Link is added - Given I send a "GET" request to "/dummy_mercures" - Then the header "Link" should contain '; rel="mercure"' - - Scenario: Checks that the Mercure Link is not added on endpoints where updates are not dispatched - Given I send a "GET" request to "/" - Then the header "Link" should not contain '; rel="mercure"' diff --git a/features/mercure/publish.feature b/features/mercure/publish.feature deleted file mode 100644 index ac0c27fa7ed..00000000000 --- a/features/mercure/publish.feature +++ /dev/null @@ -1,60 +0,0 @@ -Feature: Mercure publish support - In order to publish an Update to the Mercure hub - As a developer - I need to specify which topics I want to send the Update on - - @createSchema - # see https://github.com/api-platform/core/issues/5074 - Scenario: Checks that Mercure Updates are dispatched properly - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - When I send a "POST" request to "/issue5074/mercure_with_topics" with body: - """ - { - "name": "Hello World!", - "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then 1 Mercure update should have been sent - And the Mercure update should have topics: - | http://example.com/issue5074/mercure_with_topics/1 | - And the Mercure update should have data: - """ - { - "@context": "/contexts/MercureWithTopics", - "@id": "/issue5074/mercure_with_topics/1", - "@type": "MercureWithTopics", - "id": 1, - "name": "Hello World!" - } - """ - - Scenario: Checks that Mercure Updates are dispatched following topics configured with expression language - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - When I send a "POST" request to "/mercure_with_topics_and_get_operations" with body: - """ - { - "name": "Hello World!" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then 1 Mercure update should have been sent - And the Mercure update should have topics: - | http://example.com/mercure_with_topics_and_get_operations/1 | - | http://example.com/custom_resource/mercure_with_topics_and_get_operations/1 | - And the Mercure update should have data: - """ - { - "@context": "/contexts/MercureWithTopicsAndGetOperation", - "@id": "/mercure_with_topics_and_get_operations/1", - "@type": "MercureWithTopicsAndGetOperation", - "id": 1, - "name": "Hello World!" - } - """ diff --git a/features/push_relations/push.feature b/features/push_relations/push.feature deleted file mode 100644 index fe4a7d1de75..00000000000 --- a/features/push_relations/push.feature +++ /dev/null @@ -1,17 +0,0 @@ -@sqlite -Feature: Push relations using HTTP/2 - In order to have a fast API - As an API software developer - I need to push relations using HTTP/2 - - @createSchema - Scenario: Push the relations of a collection of items - Given there are 2 dummy objects with relatedDummy - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummies" - Then the header "Link" should be equal to '; rel="preload"; as="fetch",; rel="preload"; as="fetch",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' - - Scenario: Push the relations of an item - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummies/1" - Then the header "Link" should be equal to '; rel="preload"; as="fetch",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' diff --git a/features/sub_resources/multiple_relation.feature b/features/sub_resources/multiple_relation.feature deleted file mode 100644 index c774257aa37..00000000000 --- a/features/sub_resources/multiple_relation.feature +++ /dev/null @@ -1,61 +0,0 @@ -Feature: JSON-LD multi relation - In order to use non-resource types - As a developer - I should be able to serialize types not mapped to an API resource. - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - @!mongodb - Scenario: Get a multiple relation between to object - Given there is a relationMultiple object - When I send a "GET" request to "/dummy/1/relations/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 equal to: - """ - { - "@context": "/contexts/RelationMultiple", - "@id": "/dummy/1/relations/2", - "@type": "RelationMultiple", - "id": 1, - "first": "/dummies/1", - "second": "/dummies/2" - } - """ - - @!mongodb - Scenario: Get all multiple relation of an object - Given there is a dummy object with many multiple relation - When I send a "GET" request to "/dummy/1/relations" - 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 equal to: - """ - { - "@context": "/contexts/RelationMultiple", - "@id": "/dummy/1/relations", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy/1/relations/2", - "@type": "RelationMultiple", - "id": 1, - "first": "/dummies/1", - "second": "/dummies/2" - }, - { - "@id": "/dummy/1/relations/3", - "@type": "RelationMultiple", - "id": 2, - "first": "/dummies/1", - "second": "/dummies/3" - } - ], - "hydra:totalItems": 2 - } - """ diff --git a/features/xml/deserialization.feature b/features/xml/deserialization.feature deleted file mode 100644 index ae2d2ef66ab..00000000000 --- a/features/xml/deserialization.feature +++ /dev/null @@ -1,92 +0,0 @@ -Feature: XML Deserialization - In order to use the API with XML - As a client software developer - I need to be able to deserialize XML data - - Background: - Given I add "Accept" header equal to "application/xml" - And I add "Content-Type" header equal to "application/xml" - - @createSchema - Scenario: Posting an XML resource with a string value - When I send a "POST" request to "/resource_with_strings" with body: - """ - - - string - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - - Scenario Outline: Posting an XML resource with a boolean value - When I send a "POST" request to "/resource_with_booleans" with body: - """ - - - - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - Examples: - | value | - | true | - | false | - | 1 | - | 0 | - - Scenario Outline: Posting an XML resource with an integer value - When I send a "POST" request to "/resource_with_integers" with body: - """ - - - - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - Examples: - | value | - | 42 | - | -6 | - | 1 | - | 0 | - - @!mysql - Scenario Outline: Posting an XML resource with a float value - When I send a "POST" request to "/resource_with_floats" with body: - """ - - - - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - Examples: - | value | - | 3.14 | - | NaN | - | INF | - | -INF | - - Scenario: Posting an XML resource with a collection with only one element - When I send a "POST" request to "/dummy_properties" with body: - """ - - - - - bar - - - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" diff --git a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetChild.php b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetChild.php new file mode 100644 index 00000000000..d50e257190d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetChild.php @@ -0,0 +1,44 @@ + + * + * 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\ApiResource\PropertyFilter; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/sparse_fieldset_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +final class SparseFieldsetChild +{ + public function __construct( + #[ApiProperty(identifier: true)] + public int $id, + public string $name, + public ?string $description = null, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + return new self((int) $uriVariables['id'], 'Child #'.$uriVariables['id'], 'A description'); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php new file mode 100644 index 00000000000..4eb2f7eae91 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php @@ -0,0 +1,57 @@ + + * + * 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\ApiResource\PropertyFilter; + +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\Filter\PropertyFilter; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/sparse_fieldset_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +#[ApiFilter(PropertyFilter::class)] +final class SparseFieldsetParent +{ + public function __construct( + #[ApiProperty(identifier: true)] + public int $id, + public string $name, + public string $alias, + public string $nameConverted, + public ?SparseFieldsetChild $child = null, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + $id = (int) $uriVariables['id']; + + return new self( + $id, + 'Parent #'.$id, + 'Alias #'.$id, + 'Converted '.$id, + new SparseFieldsetChild($id, 'Child #'.$id, 'A description'), + ); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParentWithQueryParameter.php b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParentWithQueryParameter.php new file mode 100644 index 00000000000..405291bda02 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParentWithQueryParameter.php @@ -0,0 +1,54 @@ + + * + * 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\ApiResource\PropertyFilter; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Serializer\Filter\PropertyFilter; + +#[Get( + uriTemplate: '/sparse_fieldset_parents_qp/{id}', + uriVariables: ['id'], + parameters: [ + 'properties' => new QueryParameter(filter: new PropertyFilter()), + ], + provider: [self::class, 'provide'], +)] +final class SparseFieldsetParentWithQueryParameter +{ + public function __construct( + #[ApiProperty(identifier: true)] + public int $id, + public string $name, + public string $alias, + public string $nameConverted, + public ?SparseFieldsetChild $child = null, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + $id = (int) $uriVariables['id']; + + return new self( + $id, + 'Parent #'.$id, + 'Alias #'.$id, + 'Converted '.$id, + new SparseFieldsetChild($id, 'Child #'.$id, 'A description'), + ); + } +} diff --git a/tests/Functional/Filter/FilterValidationTest.php b/tests/Functional/Filter/FilterValidationTest.php new file mode 100644 index 00000000000..af74725a240 --- /dev/null +++ b/tests/Functional/Filter/FilterValidationTest.php @@ -0,0 +1,147 @@ + + * + * 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\Filter; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ArrayFilterValidator; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterValidator; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Validation built from legacy filter descriptions registered through the + * `filters` attribute on the resource. The QueryParameter equivalent is + * covered by {@see \ApiPlatform\Tests\Functional\Parameters\ValidationTest}. + */ +final class FilterValidationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [FilterValidator::class, ArrayFilterValidator::class]; + } + + protected function setUp(): void + { + $this->recreateSchema($this->getResources()); + } + + public function testRequiredFilterValid(): void + { + self::createClient()->request('GET', '/filter_validators?required=foo&required-allow-empty=&arrayRequired[foo]=', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(200); + } + + public function testRequiredFilterBlank(): void + { + self::createClient()->request('GET', '/filter_validators?required=&required-allow-empty=&arrayRequired[foo]=', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains(['detail' => 'required: This value should not be blank.']); + } + + public function testRequiredFilterMissing(): void + { + self::createClient()->request('GET', '/filter_validators', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'detail' => "required: This value should not be blank.\nrequired-allow-empty: This value should not be null.", + ]); + } + + public function testArrayRequiredValid(): void + { + self::createClient()->request('GET', '/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(200); + } + + public function testArrayRequiredMissing(): void + { + self::createClient()->request('GET', '/array_filter_validators', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'detail' => "arrayRequired[]: This value should not be blank.\nindexedArrayRequired[foo]: This value should not be blank.", + ]); + } + + public function testArrayRequiredOnlyOneKeyProvided(): void + { + self::createClient()->request('GET', '/array_filter_validators?arrayRequired[foo]=foo', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'detail' => 'indexedArrayRequired[foo]: This value should not be blank.', + ]); + + self::createClient()->request('GET', '/array_filter_validators?arrayRequired[]=foo', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'detail' => 'indexedArrayRequired[foo]: This value should not be blank.', + ]); + } + + public static function bounds(): iterable + { + yield 'maximum valid' => ['maximum=10', 200, null]; + yield 'maximum invalid' => ['maximum=11', 422, 'maximum: This value should be less than or equal to 10.']; + yield 'exclusiveMaximum valid' => ['exclusiveMaximum=9', 200, null]; + yield 'exclusiveMaximum invalid' => ['exclusiveMaximum=10', 422, 'exclusiveMaximum: This value should be less than 10.']; + yield 'minimum valid' => ['minimum=5', 200, null]; + yield 'minimum invalid' => ['minimum=0', 422, 'minimum: This value should be greater than or equal to 5.']; + yield 'exclusiveMinimum valid' => ['exclusiveMinimum=6', 200, null]; + yield 'exclusiveMinimum invalid' => ['exclusiveMinimum=5', 422, 'exclusiveMinimum: This value should be greater than 5.']; + yield 'max length valid' => ['max-length-3=123', 200, null]; + yield 'max length invalid' => ['max-length-3=1234', 422, 'max-length-3: This value is too long. It should have 3 characters or less.']; + yield 'min length valid' => ['min-length-3=123', 200, null]; + yield 'min length invalid' => ['min-length-3=12', 422, 'min-length-3: This value is too short. It should have 3 characters or more.']; + yield 'pattern valid' => ['pattern=nrettap', 200, null]; + yield 'pattern invalid' => ['pattern=not-pattern', 422, 'pattern: This value is not valid.']; + yield 'enum valid' => ['enum=in-enum', 200, null]; + yield 'enum invalid' => ['enum=not-in-enum', 422, 'enum: The value you selected is not a valid choice.']; + yield 'multipleOf valid' => ['multiple-of=4', 200, null]; + yield 'multipleOf invalid' => ['multiple-of=3', 422, 'multiple-of: This value should be a multiple of 2.']; + } + + #[DataProvider('bounds')] + public function testFilterBounds(string $extraQuery, int $expectedStatus, ?string $expectedDetail): void + { + $url = '/filter_validators?required=foo&required-allow-empty&'.$extraQuery; + + self::createClient()->request('GET', $url, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertResponseStatusCodeSame($expectedStatus); + if (null !== $expectedDetail) { + $this->assertJsonContains(['detail' => $expectedDetail]); + } + } +} diff --git a/tests/Functional/Filter/PropertyFilterTest.php b/tests/Functional/Filter/PropertyFilterTest.php new file mode 100644 index 00000000000..10764f9629f --- /dev/null +++ b/tests/Functional/Filter/PropertyFilterTest.php @@ -0,0 +1,100 @@ + + * + * 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\Filter; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PropertyFilter\SparseFieldsetChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PropertyFilter\SparseFieldsetParent; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PropertyFilter\SparseFieldsetParentWithQueryParameter; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Covers PropertyFilter sparse fieldset selection on resource relations. + * Non-resource selection is covered by {@see \ApiPlatform\Tests\Functional\JsonLd\NonResourceTest::testSparseFieldsetOnNonResourceObject}. + */ +final class PropertyFilterTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + SparseFieldsetParent::class, + SparseFieldsetParentWithQueryParameter::class, + SparseFieldsetChild::class, + ]; + } + + public function testApiFilterSelectsScalarProperties(): void + { + $response = self::createClient()->request( + 'GET', + '/sparse_fieldset_parents/1?properties[]=name&properties[]=alias&properties[]=nameConverted', + ['headers' => ['Accept' => 'application/ld+json']], + ); + + $body = $response->toArray(); + $this->assertSame('Parent #1', $body['name']); + $this->assertSame('Alias #1', $body['alias']); + // The name converter snake_cases this property at serialization time. + $this->assertSame('Converted 1', $body['name_converted']); + $this->assertArrayNotHasKey('child', $body); + } + + public function testApiFilterSelectsNestedRelationProperty(): void + { + $response = self::createClient()->request( + 'GET', + '/sparse_fieldset_parents/1?properties[]=name&properties[child][]=name', + ['headers' => ['Accept' => 'application/ld+json']], + ); + + $body = $response->toArray(); + $this->assertSame('Parent #1', $body['name']); + $this->assertSame('Child #1', $body['child']['name']); + $this->assertArrayNotHasKey('description', $body['child']); + $this->assertArrayNotHasKey('alias', $body); + } + + public function testQueryParameterSelectsScalarProperties(): void + { + $response = self::createClient()->request( + 'GET', + '/sparse_fieldset_parents_qp/1?properties[]=name&properties[]=alias', + ['headers' => ['Accept' => 'application/ld+json']], + ); + + $body = $response->toArray(); + $this->assertSame('Parent #1', $body['name']); + $this->assertSame('Alias #1', $body['alias']); + $this->assertArrayNotHasKey('child', $body); + $this->assertArrayNotHasKey('nameConverted', $body); + } + + public function testQueryParameterSelectsNestedRelationProperty(): void + { + $response = self::createClient()->request( + 'GET', + '/sparse_fieldset_parents_qp/1?properties[]=name&properties[child][]=name', + ['headers' => ['Accept' => 'application/ld+json']], + ); + + $body = $response->toArray(); + $this->assertSame('Parent #1', $body['name']); + $this->assertSame('Child #1', $body['child']['name']); + $this->assertArrayNotHasKey('description', $body['child']); + } +} diff --git a/tests/Functional/HttpCache/CacheTagsTest.php b/tests/Functional/HttpCache/CacheTagsTest.php new file mode 100644 index 00000000000..f7761c1b76e --- /dev/null +++ b/tests/Functional/HttpCache/CacheTagsTest.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\HttpCache; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\NullPurger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation1; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation2; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation3; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CacheTagsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + RelationEmbedder::class, + RelatedDummy::class, + ThirdLevel::class, + Relation1::class, + Relation2::class, + Relation3::class, + ]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HTTP Cache tags only enabled on SQLite test suite'); + } + + $this->recreateSchema($this->getResources()); + $this->purger()->clear(); + } + + public function testFullCacheTagsLifecycle(): void + { + $client = self::createClient(); + + // Create an embedded relation; collection IRIs should be purged. + $client->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'anotherRelated' => ['name' => 'Related', 'thirdLevel' => new \stdClass()], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseNotHasHeader('Cache-Tags'); + $this->assertSamePurgedIris([ + '/relation_embedders', + '/related_dummies', + '/third_levels', + ]); + + // Item GET exposes Cache-Tags. + $client->request('GET', '/relation_embedders/1'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Cache-Tags', '/third_levels/1,/related_dummies/1,/relation_embedders/1'); + + // Create a second embedded relation. + $client->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['anotherRelated' => ['name' => 'Another Related', 'thirdLevel' => new \stdClass()]], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseNotHasHeader('Cache-Tags'); + + // Collection GET aggregates per-item tags. + $client->request('GET', '/relation_embedders'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/third_levels/1,/related_dummies/1,/relation_embedders/1,/third_levels/2,/related_dummies/2,/relation_embedders/2,/relation_embedders', + ); + + // PUT purges item and related dummy. + $this->purger()->clear(); + $client->request('PUT', '/relation_embedders/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['paris' => 'France'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseNotHasHeader('Cache-Tags'); + $this->assertSamePurgedIris(['/relation_embedders', '/relation_embedders/1', '/related_dummies/1']); + + // DELETE purges item and related dummy. + $this->purger()->clear(); + $client->request('DELETE', '/relation_embedders/1'); + $this->assertResponseStatusCodeSame(204); + $this->assertResponseNotHasHeader('Cache-Tags'); + $this->assertSamePurgedIris(['/relation_embedders', '/relation_embedders/1', '/related_dummies/1']); + } + + public function testManyToManyCacheTags(): void + { + $client = self::createClient(); + + // Two Relation2 instances. + $client->request('POST', '/relation2s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $client->request('POST', '/relation2s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(201); + + // Item GET on a Relation2 lists embedded collection tag. + $client->request('GET', '/relation2s/1'); + $this->assertResponseHeaderSame('Cache-Tags', '/relation2s/1'); + + // Many-to-one purges Relation2 sibling. + $this->purger()->clear(); + $client->request('POST', '/relation1s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2' => '/relation2s/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSamePurgedIris(['/relation1s', '/relation2s/1']); + + // Replacing the relation purges old + new sides. + $this->purger()->clear(); + $client->request('PUT', '/relation1s/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2' => '/relation2s/2'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertSamePurgedIris(['/relation1s', '/relation1s/1', '/relation2s/2', '/relation2s/1']); + + // Many-to-many POST purges all referenced Relation2. + $this->purger()->clear(); + $client->request('POST', '/relation3s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2s' => ['/relation2s/1', '/relation2s/2']], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSamePurgedIris(['/relation3s', '/relation2s/1', '/relation2s/2']); + + // Collection GET aggregates tags including the collection IRI. + $client->request('GET', '/relation3s'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Cache-Tags', '/relation2s/1,/relation2s/2,/relation3s/1,/relation3s'); + + // Updating a many-to-many removes a sibling and purges the old & new ones. + $this->purger()->clear(); + $client->request('PUT', '/relation3s/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2s' => ['/relation2s/2']], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertSamePurgedIris(['/relation3s', '/relation3s/1', '/relation2s/2', '/relation2s', '/relation2s/1']); + + // Deleting the m2m owner purges the remaining sibling. + $this->purger()->clear(); + $client->request('DELETE', '/relation3s/1'); + $this->assertResponseStatusCodeSame(204); + $this->assertSamePurgedIris(['/relation3s', '/relation3s/1', '/relation2s/2']); + } + + private function assertSamePurgedIris(array $expected): void + { + $purged = $this->purger()->getIris(); + sort($expected); + sort($purged); + $this->assertSame($expected, $purged); + } + + private function purger(): NullPurger + { + $purger = static::getContainer()->get('test.api_platform.http_cache.purger'); + \assert($purger instanceof NullPurger); + + return $purger; + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/HttpCache/HeadersTest.php b/tests/Functional/HttpCache/HeadersTest.php new file mode 100644 index 00000000000..4d0c810f75d --- /dev/null +++ b/tests/Functional/HttpCache/HeadersTest.php @@ -0,0 +1,48 @@ + + * + * 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\HttpCache; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HeadersTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationEmbedder::class]; + } + + public function testDefaultCacheHeaders(): void + { + $this->recreateSchema([RelationEmbedder::class]); + + $response = self::createClient()->request('GET', '/relation_embedders'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Etag', '"032297ac74d75a50"'); + $this->assertResponseHeaderSame('Cache-Control', 'max-age=60, public, s-maxage=3600'); + // Vary headers may come on multiple lines depending on the framework version. + $this->assertSame( + ['accept', 'cookie', 'accept-language'], + array_map('strtolower', $response->getHeaders()['vary'] ?? []), + ); + } +} diff --git a/tests/Functional/HttpCache/PushRelationsTest.php b/tests/Functional/HttpCache/PushRelationsTest.php new file mode 100644 index 00000000000..7d6899e557a --- /dev/null +++ b/tests/Functional/HttpCache/PushRelationsTest.php @@ -0,0 +1,88 @@ + + * + * 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\HttpCache; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PushRelationsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class, RelatedDummy::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HTTP/2 push only enabled on SQLite test suite'); + } + + $this->recreateSchema([Dummy::class, RelatedDummy::class]); + $this->loadDummies(2); + } + + public function testCollectionPushesRelatedIris(): void + { + self::createClient()->request('GET', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseHeaderSame( + 'Link', + '; rel="preload"; as="fetch",; rel="preload"; as="fetch",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', + ); + } + + public function testItemPushesRelatedIri(): void + { + self::createClient()->request('GET', '/dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseHeaderSame( + 'Link', + '; rel="preload"; as="fetch",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', + ); + } + + private function loadDummies(int $count): void + { + $manager = static::getContainer()->get('doctrine')->getManager(); + + for ($i = 1; $i <= $count; ++$i) { + $related = new RelatedDummy(); + $related->setName('RelatedDummy #'.$i); + + $dummy = new Dummy(); + $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/HttpCache/TagCollectorTest.php b/tests/Functional/HttpCache/TagCollectorTest.php new file mode 100644 index 00000000000..1498c67cdc5 --- /dev/null +++ b/tests/Functional/HttpCache/TagCollectorTest.php @@ -0,0 +1,211 @@ + + * + * 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\HttpCache; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ExtraPropertiesOnProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation2; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation3; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorCustom; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class TagCollectorTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + RelationEmbedder::class, + RelatedDummy::class, + ThirdLevel::class, + ExtraPropertiesOnProperty::class, + Relation2::class, + Relation3::class, + ]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Custom tag collector is only enabled on SQLite test suite'); + } + + // Force a fresh kernel so the custom collector replacement is in effect + // before any service that depends on it is instantiated. + static::ensureKernelShutdown(); + self::bootKernel(); + $container = static::getContainer(); + $container->set( + 'api_platform.http_cache.tag_collector', + new TagCollectorCustom($container->get('api_platform.iri_converter')), + ); + + $this->recreateSchema($this->getResources()); + } + + /** + * Returns a client that keeps the kernel alive between HTTP requests so the + * tag_collector override registered in setUp survives across calls. + */ + private function disableRebootClient(): \ApiPlatform\Symfony\Bundle\Test\Client + { + $client = self::createClient(); + $client->getKernelBrowser()->disableReboot(); + + return $client; + } + + public function testCustomTagsOnEmptyResource(): void + { + $this->disableRebootClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseNotHasHeader('Cache-Tags'); + + $this->disableRebootClient()->request('GET', '/relation_embedders/1'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Cache-Tags', '/RE/1#anotherRelated,/RE/1#related,/RE/1'); + } + + public function testCustomTagsForEmbeddedRelationJsonLd(): void + { + $this->disableRebootClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['anotherRelated' => ['name' => 'Related']], + ]); + $this->assertResponseStatusCodeSame(201); + + $this->disableRebootClient()->request('GET', '/relation_embedders/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/related_dummies/1#thirdLevel,/related_dummies/1,/RE/1#anotherRelated,/RE/1#related,/RE/1', + ); + $this->assertJsonContains([ + '@context' => '/contexts/RelationEmbedder', + '@id' => '/relation_embedders/1', + '@type' => 'RelationEmbedder', + 'krondstadt' => 'Krondstadt', + 'anotherRelated' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'symfony', + 'thirdLevel' => null, + ], + 'related' => null, + ]); + } + + public function testCustomTagsForEmbeddedRelationHal(): void + { + $this->disableRebootClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['anotherRelated' => ['name' => 'Related']], + ]); + + $this->disableRebootClient()->request('GET', '/relation_embedders/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/RE/1,/related_dummies/1,/related_dummies/1#thirdLevel,/RE/1#anotherRelated,/RE/1#related', + ); + } + + public function testCustomTagsForEmbeddedRelationJsonApi(): void + { + $this->disableRebootClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['anotherRelated' => ['name' => 'Related']], + ]); + + $this->disableRebootClient()->request('GET', '/relation_embedders/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/RE/1,/RE/1#anotherRelated,/RE/1#related', + ); + } + + public function testCustomTagsFromApiPropertyExtraProperties(): void + { + $this->disableRebootClient()->request('POST', '/extra_properties_on_properties', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(201); + + $this->disableRebootClient()->request('GET', '/extra_properties_on_properties/1'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/extra_properties_on_properties/1#overrideRelationTag,/extra_properties_on_properties/1', + ); + } + + /** + * Replaces the three "Get a Relation3 (test collection of links; ...)" behat + * scenarios. Each format asserts the same Cache-Tags set because the + * resource collection only contains link-only Relation2 references. + */ + public function testCustomTagsForManyToManyCollections(): void + { + $client = $this->disableRebootClient(); + $client->request('POST', '/relation2s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $client->request('POST', '/relation2s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $client->request('POST', '/relation3s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2s' => ['/relation2s/1', '/relation2s/2']], + ]); + $this->assertResponseStatusCodeSame(201); + + // Each format produces a different ordering of tags but the set must match. + $expected = ['/relation3s/1#relation2s', '/relation3s/1', '/relation3s']; + sort($expected); + + foreach (['application/ld+json', 'application/hal+json', 'application/vnd.api+json'] as $accept) { + $response = $client->request('GET', '/relation3s', ['headers' => ['Accept' => $accept]]); + $this->assertResponseStatusCodeSame(200); + $actual = explode(',', $response->getHeaders()['cache-tags'][0] ?? ''); + sort($actual); + $this->assertSame($expected, $actual, \sprintf('Cache-Tags mismatch for %s', $accept)); + } + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/Issue5926Test.php b/tests/Functional/Issue5926Test.php new file mode 100644 index 00000000000..2f3aa2e11c8 --- /dev/null +++ b/tests/Functional/Issue5926Test.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; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5926\TestIssue5926; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * @see https://github.com/api-platform/core/issues/5926 + */ +final class Issue5926Test extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [TestIssue5926::class]; + } + + public static function formats(): iterable + { + yield ['application/json', 'application/json; charset=utf-8']; + yield ['application/vnd.api+json', 'application/vnd.api+json; charset=utf-8']; + yield ['application/ld+json', 'application/ld+json; charset=utf-8']; + yield ['application/hal+json', 'application/hal+json; charset=utf-8']; + } + + #[DataProvider('formats')] + public function testGetWriteResourceWithEmbeddedNonResourceCollection(string $accept, string $expectedContentType): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/test_issue5926s/1', [ + 'headers' => ['Accept' => $accept], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', $expectedContentType); + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/Json/InputOutputTest.php b/tests/Functional/Json/InputOutputTest.php new file mode 100644 index 00000000000..61846f32f71 --- /dev/null +++ b/tests/Functional/Json/InputOutputTest.php @@ -0,0 +1,67 @@ + + * + * 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\Json; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [User::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([User::class]); + } + + public function testPasswordResetRequest(): void + { + self::createClient()->request('POST', '/users_reset/password_reset_request', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['email' => 'user@example.com'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/json; charset=utf-8'); + $this->assertJsonEquals(['emailSentAt' => '2019-07-05T15:44:00+00:00']); + } + + public function testPasswordResetRequestForUnknownUser(): void + { + self::createClient()->request('POST', '/users_reset/password_reset_request', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['email' => 'does-not-exist@example.com'], + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains(['detail' => 'User does not exist.']); + } +} diff --git a/tests/Functional/Json/RelationTest.php b/tests/Functional/Json/RelationTest.php new file mode 100644 index 00000000000..03a04b8d8e9 --- /dev/null +++ b/tests/Functional/Json/RelationTest.php @@ -0,0 +1,219 @@ + + * + * 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\Json; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Validates that JSON requests on resources accepting application/ld+json + * responses cover embedded creation, IRI relations and plain identifiers. + */ +final class RelationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = true; + + public static function getResources(): array + { + return [ + ThirdLevel::class, + RelationEmbedder::class, + RelatedDummy::class, + Dummy::class, + ]; + } + + protected function setUp(): void + { + $this->recreateSchema($this->getResources()); + } + + public function testCreateThirdLevelReturnsLdJson(): void + { + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['level' => 3], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/ThirdLevel', + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'fourthLevel' => null, + 'badFourthLevel' => null, + 'id' => 1, + 'level' => 3, + 'test' => true, + 'relatedDummies' => [], + ]); + } + + public function testCreateEmbeddedRelation(): void + { + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel']], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/RelationEmbedder', + '@id' => '/relation_embedders/1', + '@type' => 'RelationEmbedder', + 'krondstadt' => 'Krondstadt', + 'anotherRelated' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'laravel', + 'thirdLevel' => null, + ], + 'related' => null, + ]); + } + + public function testReplaceEmbeddedRelationCreatesNewRelated(): void + { + // Bootstrap a RelationEmbedder with a related dummy. + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel']], + ]); + + self::createClient()->request('PUT', '/relation_embedders/1', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel2']], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@id' => '/relation_embedders/1', + 'anotherRelated' => [ + '@id' => '/related_dummies/2', + '@type' => 'https://schema.org/Product', + 'symfony' => 'laravel2', + 'thirdLevel' => null, + ], + ]); + } + + public function testUpdateEmbeddedRelationUsingIri(): void + { + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel']], + ]); + + self::createClient()->request('PUT', '/relation_embedders/1', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['id' => '/related_dummies/1', 'symfony' => 'API Platform']], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@id' => '/relation_embedders/1', + 'anotherRelated' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'API Platform', + 'thirdLevel' => null, + ], + ]); + } + + public function testUpdateEmbeddedRelationUsingPlainIdentifier(): void + { + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel']], + ]); + + self::createClient()->request('PUT', '/relation_embedders/1', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['id' => 1, 'symfony' => 'API Platform 2']], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@id' => '/relation_embedders/1', + 'anotherRelated' => [ + '@id' => '/related_dummies/1', + 'symfony' => 'API Platform 2', + ], + ]); + } + + public function testCreateRelatedDummyWithPlainIdentifierForRelation(): void + { + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['level' => 3], + ]); + + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['thirdLevel' => '1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/RelatedDummy', + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'thirdLevel' => [ + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'fourthLevel' => null, + ], + ]); + } + + public function testCreateDummyWithPlainIdentifiersForRelations(): void + { + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => new \stdClass(), + ]); + + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'relatedDummy' => '1', + 'relatedDummies' => ['1'], + 'name' => 'Dummy with plain relations', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'relatedDummy' => '/related_dummies/1', + 'relatedDummies' => ['/related_dummies/1'], + 'name' => 'Dummy with plain relations', + ]); + } +} diff --git a/tests/Functional/Mercure/MercureTest.php b/tests/Functional/Mercure/MercureTest.php new file mode 100644 index 00000000000..4304e7b5ba5 --- /dev/null +++ b/tests/Functional/Mercure/MercureTest.php @@ -0,0 +1,149 @@ + + * + * 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\Mercure; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5074\MercureWithTopics; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MercureWithTopicsAndGetOperation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Mercure\TestHub; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Mercure\Update; + +final class MercureTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + DummyMercure::class, + RelatedDummy::class, + MercureWithTopics::class, + MercureWithTopicsAndGetOperation::class, + ]; + } + + public function testDiscoveryLinkOnMercureResource(): void + { + $this->recreateSchema([DummyMercure::class, RelatedDummy::class]); + + $response = self::createClient()->request('GET', '/dummy_mercures', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertContains( + '; rel="mercure"', + $response->getHeaders()['link'], + ); + } + + public function testNoDiscoveryLinkOnNonMercureEndpoint(): void + { + $response = self::createClient()->request('GET', '/'); + + $this->assertNotContains( + '; rel="mercure"', + $response->getHeaders()['link'] ?? [], + ); + } + + public function testPublishUpdateOnPostWithIriTopic(): void + { + $this->recreateSchema([MercureWithTopics::class]); + $hub = $this->resetTestHub(); + + self::createClient()->request('POST', '/issue5074/mercure_with_topics', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'name' => 'Hello World!', + 'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + + $updates = $hub->getUpdates(); + $this->assertCount(1, $updates); + /** @var Update $update */ + $update = $updates[0]; + $this->assertSame(['http://localhost/issue5074/mercure_with_topics/1'], array_values($update->getTopics())); + $this->assertJsonStringEqualsJsonString( + json_encode([ + '@context' => '/contexts/MercureWithTopics', + '@id' => '/issue5074/mercure_with_topics/1', + '@type' => 'MercureWithTopics', + 'id' => 1, + 'name' => 'Hello World!', + ], \JSON_THROW_ON_ERROR), + $update->getData(), + ); + } + + public function testPublishUpdateWithExpressionLanguageTopics(): void + { + $this->recreateSchema([MercureWithTopicsAndGetOperation::class]); + $hub = $this->resetTestHub(); + + self::createClient()->request('POST', '/mercure_with_topics_and_get_operations', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['name' => 'Hello World!'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + + $updates = $hub->getUpdates(); + $this->assertCount(1, $updates); + /** @var Update $update */ + $update = $updates[0]; + $this->assertSame([ + 'http://localhost/mercure_with_topics_and_get_operations/1', + 'http://localhost/custom_resource/mercure_with_topics_and_get_operations/1', + ], array_values($update->getTopics())); + $this->assertJsonStringEqualsJsonString( + json_encode([ + '@context' => '/contexts/MercureWithTopicsAndGetOperation', + '@id' => '/mercure_with_topics_and_get_operations/1', + '@type' => 'MercureWithTopicsAndGetOperation', + 'id' => 1, + 'name' => 'Hello World!', + ], \JSON_THROW_ON_ERROR), + $update->getData(), + ); + } + + private function resetTestHub(): TestHub + { + $hub = static::getContainer()->get('mercure.hub.default.test_hub'); + \assert($hub instanceof TestHub); + + $reflection = new \ReflectionProperty(TestHub::class, 'updates'); + $reflection->setValue($hub, []); + + return $hub; + } +} diff --git a/tests/Functional/SubResource/MultipleRelationTest.php b/tests/Functional/SubResource/MultipleRelationTest.php new file mode 100644 index 00000000000..f81b23074aa --- /dev/null +++ b/tests/Functional/SubResource/MultipleRelationTest.php @@ -0,0 +1,94 @@ + + * + * 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\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationMultiple; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MultipleRelationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationMultiple::class, Dummy::class]; + } + + public function testGetMultipleRelationItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/dummy/1/relations/2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/RelationMultiple', + '@id' => '/dummy/1/relations/2', + '@type' => 'RelationMultiple', + 'id' => 1, + 'first' => '/dummies/1', + 'second' => '/dummies/2', + ]); + } + + public function testGetMultipleRelationCollection(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/dummy/1/relations', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/RelationMultiple', + '@id' => '/dummy/1/relations', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/dummy/1/relations/2', + '@type' => 'RelationMultiple', + 'id' => 1, + 'first' => '/dummies/1', + 'second' => '/dummies/2', + ], + [ + '@id' => '/dummy/1/relations/3', + '@type' => 'RelationMultiple', + 'id' => 2, + 'first' => '/dummies/1', + 'second' => '/dummies/3', + ], + ], + 'hydra:totalItems' => 2, + ]); + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/Xml/DeserializationTest.php b/tests/Functional/Xml/DeserializationTest.php new file mode 100644 index 00000000000..3ef98f016cf --- /dev/null +++ b/tests/Functional/Xml/DeserializationTest.php @@ -0,0 +1,173 @@ + + * + * 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\Xml; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithBoolean; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithFloat; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithString; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class DeserializationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + ResourceWithString::class, + ResourceWithBoolean::class, + ResourceWithInteger::class, + ResourceWithFloat::class, + DummyProperty::class, + ]; + } + + private const XML_HEADERS = [ + 'Accept' => 'application/xml', + 'Content-Type' => 'application/xml', + ]; + + public function testPostStringResource(): void + { + $this->recreateSchema([ResourceWithString::class]); + + self::createClient()->request('POST', '/resource_with_strings', [ + 'headers' => self::XML_HEADERS, + 'body' => <<<'XML' + + + string + + XML, + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public static function booleanValues(): iterable + { + yield ['true']; + yield ['false']; + yield ['1']; + yield ['0']; + } + + #[DataProvider('booleanValues')] + public function testPostBooleanResource(string $value): void + { + $this->recreateSchema([ResourceWithBoolean::class]); + + self::createClient()->request('POST', '/resource_with_booleans', [ + 'headers' => self::XML_HEADERS, + 'body' => \sprintf(<<<'XML' + + + %s + + XML, $value), + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public static function integerValues(): iterable + { + yield ['42']; + yield ['-6']; + yield ['1']; + yield ['0']; + } + + #[DataProvider('integerValues')] + public function testPostIntegerResource(string $value): void + { + $this->recreateSchema([ResourceWithInteger::class]); + + self::createClient()->request('POST', '/resource_with_integers', [ + 'headers' => self::XML_HEADERS, + 'body' => \sprintf(<<<'XML' + + + %s + + XML, $value), + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public static function floatValues(): iterable + { + yield ['3.14']; + yield ['NaN']; + yield ['INF']; + yield ['-INF']; + } + + #[DataProvider('floatValues')] + public function testPostFloatResource(string $value): void + { + if ($this->isMysql()) { + $this->markTestSkipped('MySQL does not support NaN/Inf floats'); + } + + $this->recreateSchema([ResourceWithFloat::class]); + + self::createClient()->request('POST', '/resource_with_floats', [ + 'headers' => self::XML_HEADERS, + 'body' => \sprintf(<<<'XML' + + + %s + + XML, $value), + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public function testPostSingleElementCollection(): void + { + $this->recreateSchema([DummyProperty::class]); + + self::createClient()->request('POST', '/dummy_properties', [ + 'headers' => self::XML_HEADERS, + 'body' => <<<'XML' + + + + + bar + + + + XML, + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } +} diff --git a/tests/RecreateSchemaTrait.php b/tests/RecreateSchemaTrait.php index 7ab756e4604..a5f53cb9326 100644 --- a/tests/RecreateSchemaTrait.php +++ b/tests/RecreateSchemaTrait.php @@ -29,11 +29,18 @@ private function recreateSchema(array $classes = []): void if ($manager instanceof DocumentManager) { $schemaManager = $manager->getSchemaManager(); + $firstDocumentClass = null; foreach ($classes as $c) { $class = str_contains($c, 'Entity') ? str_replace('Entity', 'Document', $c) : $c; + $firstDocumentClass ??= $class; $schemaManager->dropDocumentCollection($class); } + // Reset INCREMENT id counters; otherwise IDs persist across test methods. + if (null !== $firstDocumentClass) { + $manager->getDocumentDatabase($firstDocumentClass)->dropCollection('doctrine_increment_ids'); + } + return; }