From 195e6d3d9f07aa23a379259ef3a5d36f50c8546d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 11 May 2026 15:35:10 +0200 Subject: [PATCH 1/7] test(jsonld,hydra,hal): migrate behat features to ApiTestCase (#7957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(jsonld): migrate trivial behat features to ApiTestCase Replaces 9 jsonld behat features with ApiTestCase functional tests backed by isolated static-provider fixtures (no Doctrine entities, except PropertyCollectionIriOnlyTest which keeps the existing entities to preserve Link/uriVariables semantics). Covers disable_id_generation, no_output, getter_setter_renaming, interface_as_resource, interface_dto_output, max_depth, json_serializable, iri_only and drops the debug-only inheritance scenarios. * test(jsonld): migrate context/non-resource and abs/net URL features Replaces context.feature (entrypoint, resource, embed-relation, extended jsonldContext), non_resource.feature (genid, sparse fieldsets, DateTime, plain object, non-resource relation), absolute_url.feature, network_path.feature and hydra/absolute.feature (paged hydra:view). Per-operation urlGenerationStrategy replaces the legacy app-level configuration; new fixtures use static providers and ArrayPaginator. * test(hydra): migrate entrypoint, error and item_uri_template features Replaces the entrypoint, error and item_uri_template hydra behat features with ApiTestCase functional tests. Error scenarios use ValidationException + BadRequestHttpException via dedicated processors; item_uri_template covers cars/brands plus the existing issue5662 and CollectionReferencingItem fixtures registered through SetupClassResources. * test(hydra): migrate collection, docs and input_output features Replaces the remaining hydra/jsonld behat features: * hydra/collection.feature: pagination, filters, partial pagination, no-prefix mode and cursor pagination (cursor variant uses SoMany). * hydra/docs.feature: simplified to assert vocabulary, supportedClass, property/operation surface and deprecations on a dedicated fixture. * jsonld/input_output.feature: custom input/output DTO, output: false, input: false, full input/output cycle. Drops legacy @v3 / messenger / DataTransformer scenarios that no longer reflect 4.x semantics. * test(jsonld): close coverage gaps left by behat migration * abs/network URL tests now run against http://example.com via base_uri override, matching the original behat host. * HydraDocsTest exercises the original assertions on a richer fixture: subClassOf via types, range/maxCardinality on relations, schema:FindAction type on GET, per-method hydra:title and description, deprecation propagating from a deprecated resource into the entrypoint. * InputOutputDtoTest restores the three legacy @v3 collection-DTO scenarios (genid for resource without id, custom itemUriTemplate, identifier-bearing resource still falling back to genid when no item operation exists). * New EntityClassWithDateTimeTest covers JSON-LD on a resource backed by a Doctrine entity through stateOptions. * HydraCollectionTest now uses a partial-only paginator implementation, asserting that hydra:view drops first/last and keeps next/previous. * RenamedGetterSetterTest restores the exact @id assertion (collection URI fallback when the resource has no identifier). * chore(tests): remove fixtures orphaned by jsonld behat migration EntityWithRenamedGetterAndSetter, EntityWithDtoOutput, JsonldContextDummy and MaxDepthEagerDummy (entity + document) were only referenced by the deleted behat features. Other fixtures touched by the migration (IriOnlyDummy, AbsoluteUrlDummy, NetworkPathDummy, MaxDepthDummy, ContainNonResource, PlainObjectDummy, NonRelationResource, DisableIdGeneration) are still referenced from DoctrineContext, the JSON Schema command test, the jsonapi feature suite or HAL tests, so they stay. * chore(tests): fix references orphaned by jsonld behat migration DtoOutput was defined inside EntityWithDtoOutput.php (deleted in fbfe043d0); src/JsonSchema/Tests/DefinitionNameFactoryTest.php still imports it as a class-string for definition-name generation. Re-adds a minimal class. Also narrows InterfaceTaxonImpl::getCode() to non-nullable string (covariant) which phpstan now catches. * test(jsonld): fix CI failures from behat migration Two fixes for the JsonLd functional tests added yesterday: * Consolidate dual #[ApiResource] declarations sharing the same shortName on AbsoluteUrlChild, NetworkPathResource and UriTemplateCar into a single attribute with all operations. Avoids the 4.2-deprecated duplicate-shortName path that was failing the no-deprecation and Symfony-dev PHPUnit jobs. * Skip MongoDB-only paths in ItemUriTemplateHydraTest and the UserResource branch of InputOutputDtoTest where the underlying fixtures live in the Entity namespace and aren't loaded under the Document kernel; skip InputOutputDtoTest::testCreateNoInputResource in event-listener mode where PlaceholderAction cannot resolve $data for input:false POST. * test(hal): migrate behat features to ApiTestCase Replaces the eleven hal behat features with ApiTestCase functional tests under tests/Functional/Hal, mirroring the jsonld/hydra migration done yesterday. Most use static-provider fixtures; PropertyCollectionIriOnly and TableInheritance keep the existing entity fixtures (still referenced by features/main/table_inheritance.feature). MaxDepthEagerDummy was already deleted in fbfe043d0 — this restores coverage for that scenario under HalMaxDepth. With features/hal, features/jsonld and features/hydra all gone, the ld-api-hal-hydra shard reduced to features/jsonapi alone; renames the shard accordingly. * cs: enforce yoda style in JsonLd CollectionPagedResource * chore(tests): move root JSON-LD tests into JsonLd/ subdir * HydraTest -> JsonLd/HydraHideFromDocsTest (descriptive name reflects what the single test asserts: hiding hydra:supportedClass and operations) * LinkedDataPlatformTest -> JsonLd/LinkedDataPlatformTest (LDP is JSON-LD) * ItemUriTemplateTest -> JsonLd/ItemUriTemplateNotFoundTest (rename testIssue6718 to testNotFoundOnInvalidItemUriTemplateRelation) * chore(tests): split monolithic JsonLdTest into per-feature files Replace the 9-method root JsonLdTest.php with 6 focused JsonLd/* test files, one per feature: * InputDtoIriDenormalizationTest (input DTO with IRI relation) * ContextOutputTest (output DTO @context shape, ignored properties) * GenIdFalseTest (genId:false at resource and nested levels) * PolymorphicResourceCollectionTest (per-item @type in collections) * ItemUriTemplateCollectionTest (itemUriTemplate as @id, with stateOptions) * MultiResourceContextTest (correct shortName per ApiResource variant) Each file scopes its setUp/recreateSchema to only the entities it uses, and replaces issue-numbered method names with descriptive ones. * chore(tests): split monolithic JsonApiTest into JsonApi/ subdir Replace the 8-method root JsonApiTest.php with 4 focused JsonApi/* test files, mirroring the JsonLd/ and Hal/ subdir layout: * JsonApi/ErrorTest (ErrorResource rendered in vnd.api+json) * JsonApi/IdentifierModeTest (4 use_iri_as_id:false tests sharing the bootJsonApiKernel helper) * JsonApi/IriModeTest (default use_iri_as_id:true mode) * JsonApi/InputDtoTest (POST with input DTOs, preserved attributes and required constructor args) Each file scopes its fixtures to only what its tests use. * test(security): migrate authorization behat features to ApiTestCase Replaces features/authorization/{deny,legacy_deny}.feature with functional tests under tests/Functional/Authorization, preserving full scenario coverage. Each @link_security scenario lives in its own test so the positive and negative paths are asserted independently. Drops features/authorization from the misc behat shard; the SecuredDummy-with-related-dummies fixture step stays since features/graphql/authorization.feature still uses it. * test(jsonapi): migrate behat features to ApiTestCase Replaces 13 jsonapi behat features with ApiTestCase functional tests backed by static-provider fixtures (CollectionUriTemplateTest reuses the existing PropertyCollectionIriOnly entities to preserve Link semantics). Covers errors, absolute/network URLs, item/collection URI templates, circular refs, non-resource handling, DTO output, ordering, filtering with sparse fieldsets, pagination, entrypoint, CRUD with relationships, and include= related-resources resolution. * test(jsonapi): back CRUD and inclusion tests with Doctrine persistence CrudTest now uses Dummy/RelatedDummy/ThirdLevel/RelationEmbedder entities through recreateSchema/persist/flush instead of static processors that fabricated ids, matching the authorization migration pattern. RelatedResourcesInclusionTest grows from 5 partial scenarios to all 15 original behat scenarios (many-to-one, many-to-many, dedup, path-based, collection variants) backed by DummyProperty/DummyGroup/ FourthLevel/RelatedOwningDummy. The 6 now-unused static JsonApi fixtures are dropped. * chore(ci): fix jsonapi migration follow-ups * Drop empty `jsonapi` behat shard from CI matrix (features/jsonapi was deleted in 0bf7af524). * PHPStan: replace `is_int($offset)` always-true narrowing with an upfront page-range guard in PaginationDummy. * CS: prefix `array_slice` with `\` in OrderingTest per project style. * test(security): skip DenyTest ORM-id-bound scenarios on MongoDB Seven DenyTest cases assume the freshly-seeded SecuredDummy gets id=1 on every run. The MongoDB ODM INCREMENT strategy keeps its counter outside the dropped document collection, so subsequent test runs re-target a non-existent id and the API returns 404 instead of the expected 403 / 200. Skip them on MongoDB to match the existing isMongoDB() pattern used by the link-security tests in this file. Also baseline the multi-shortName deprecation triggered by RelatedLinkedDummy (used only by DenyTest now) so the phpunit-no-deprecations CI job stays green. * test(security): modernize DenyTest fixtures, drop INCREMENT id assumptions * RelatedLinkedDummy (Entity + Document) gives each #[ApiResource] a distinct shortName so the multi-shortName 4.2 deprecation no longer fires. The baseline entry added in 8f0cb0909 is reverted. * DenyTest / LegacyDenyTest no longer hard-code /secured_dummies/1: POSTs now return the IRI which subsequent GET/PUT calls consume. seedLinkedDummy switches to API POST and returns the created ids, so the helper works on both ORM and ODM. This removes seven isMongoDB() skips and the failures from the MongoDB CI job (the ODM INCREMENT counter survives collection drops and never gives id=1 twice in a row). Verified locally against both APP_ENV=test and APP_ENV=mongodb. * test(hal,jsonld): close remaining behat migration coverage gaps Add PUT/PATCH on HAL relation embedder, PUT on HAL max-depth resource, JSON-LD messenger and DataTransformerInitializer scenarios. Restore the PropertyFilter on JsonLdNonResourceContainer (lost during migration) and tighten the sparse-fieldset assertion so excluded keys are checked. * test(jsonld): cover Issue5438 inheritance IRIs The deleted features/jsonld/inheritance.feature only had "print last JSON response" steps. Replace with a real assertion that confirms each collection member uses its concrete subtype's URI template (Contractor5438 → /contractor_5438/{id}, Employee5438 → /employee_5438/{id}) — the original behavior fixed in #5449. (cherry picked from commit 1d7695d83736ba763e1a0e2ba7c799975594e549) --- .github/workflows/ci.yml | 4 +- features/authorization/deny.feature | 319 ---- features/authorization/legacy_deny.feature | 96 - features/hal/absolute_url.feature | 119 -- features/hal/collection.feature | 637 ------- features/hal/collection_uri_template.feature | 81 - features/hal/hal.feature | 224 --- features/hal/input_output.feature | 48 - features/hal/item_uri_template.feature | 128 -- features/hal/max_depth.feature | 69 - features/hal/network_path.feature | 117 -- features/hal/non_resource.feature | 45 - features/hal/problem.feature | 52 - features/hal/table_inheritance.feature | 141 -- features/hydra/absolute.feature | 23 - features/hydra/collection.feature | 596 ------ features/hydra/docs.feature | 84 - features/hydra/entrypoint.feature | 30 - features/hydra/error.feature | 138 -- features/hydra/item_uri_template.feature | 237 --- features/jsonapi/absolute_url.feature | 125 -- .../jsonapi/collection_attributes.feature | 20 - .../jsonapi/collection_uri_template.feature | 60 - features/jsonapi/errors.feature | 63 - features/jsonapi/filtering.feature | 51 - features/jsonapi/input_output.feature | 59 - features/jsonapi/item_uri_template.feature | 200 -- features/jsonapi/jsonapi.feature | 257 --- features/jsonapi/network_path.feature | 125 -- features/jsonapi/non_resource.feature | 126 -- features/jsonapi/ordering.feature | 144 -- features/jsonapi/pagination.feature | 42 - .../related-resouces-inclusion.feature | 1637 ----------------- features/jsonld/absolute_url.feature | 83 - features/jsonld/context.feature | 88 - features/jsonld/disable_id_generation.feature | 9 - .../jsonld/getter_setter_renaming.feature | 25 - features/jsonld/inheritance.feature | 12 - features/jsonld/input_output.feature | 454 ----- features/jsonld/interface_as_resource.feature | 57 - features/jsonld/interface_dto_output.feature | 11 - features/jsonld/iri_only.feature | 95 - features/jsonld/json_serializable.feature | 72 - features/jsonld/max_depth.feature | 43 - features/jsonld/network_path.feature | 86 - features/jsonld/no_output.feature | 10 - features/jsonld/non_resource.feature | 145 -- .../TestBundle/ApiResource/DtoOutput.php | 21 + .../ApiResource/EntityWithDtoOutput.php | 71 - .../ApiResource/Hal/AbsoluteUrlChild.php | 78 + .../ApiResource/Hal/AbsoluteUrlParent.php | 60 + .../Hal/CollectionPagedResource.php | 122 ++ .../ApiResource/Hal/CustomOutputResource.php | 65 + .../ApiResource/Hal/HalRelatedResource.php | 49 + .../ApiResource/Hal/HalThirdLevel.php | 45 + .../ApiResource/Hal/MaxDepthResource.php | 79 + .../ApiResource/Hal/NetworkPathParent.php | 57 + .../ApiResource/Hal/NetworkPathResource.php | 78 + .../ApiResource/Hal/NonResourceContainer.php | 69 + .../ApiResource/Hal/ProblemRelation.php | 45 + .../ApiResource/Hal/ProblemResource.php | 46 + .../ApiResource/Hal/RelationEmbedder.php | 86 + .../ApiResource/Hal/UriTemplateCar.php | 85 + .../ApiResource/JsonApi/AbsoluteUrlDummy.php | 68 + .../JsonApi/AbsoluteUrlRelationDummy.php | 61 + .../ApiResource/JsonApi/CircularReference.php | 60 + .../JsonApi/CustomOutputResource.php | 66 + .../ApiResource/JsonApi/EntrypointDummy.php | 54 + .../ApiResource/JsonApi/ErrorProblem.php | 60 + .../ApiResource/JsonApi/FilteringDummy.php | 75 + .../ApiResource/JsonApi/FilteringProperty.php | 56 + .../ApiResource/JsonApi/NetworkPathDummy.php | 68 + .../JsonApi/NetworkPathRelationDummy.php | 61 + .../JsonApi/NonRelationResource.php | 59 + .../JsonApi/NonResourceContainer.php | 70 + .../ApiResource/JsonApi/OrderingDummy.php | 75 + .../ApiResource/JsonApi/PaginationDummy.php | 71 + .../JsonApi/PlainObjectResource.php | 59 + .../ApiResource/JsonApi/UriTemplateCar.php | 86 + .../JsonLd/AbsolutePagedResource.php | 51 + .../ApiResource/JsonLd/AbsoluteUrlChild.php | 78 + .../ApiResource/JsonLd/AbsoluteUrlParent.php | 60 + .../ApiResource/JsonLd/CollectionNoPrefix.php | 44 + .../JsonLd/CollectionPagedResource.php | 124 ++ .../JsonLd/CustomInputResource.php | 74 + .../JsonLd/CustomOutputResource.php | 65 + .../JsonLd/DateTimeOnlyResource.php | 46 + .../JsonLd/DisableIdGenAnonymous.php | 47 + .../ApiResource/JsonLd/DummyCollectionDto.php | 54 + .../JsonLd/DummyFooCollectionDto.php | 69 + .../JsonLd/DummyIdCollectionDto.php | 62 + .../ApiResource/JsonLd/GenIdFalseProperty.php | 58 + .../JsonLd/HydraDocsDeprecated.php | 50 + .../ApiResource/JsonLd/HydraDocsRelated.php | 49 + .../ApiResource/JsonLd/HydraDocsResource.php | 71 + .../ApiResource/JsonLd/HydraErrorResource.php | 70 + .../JsonLd/InputOutputResource.php | 117 ++ .../JsonLd/InterfaceDtoOutputResource.php | 63 + .../ApiResource/JsonLd/InterfaceTaxon.php | 55 + .../JsonLd/InterfaceTaxonProduct.php | 50 + .../ApiResource/JsonLd/IriOnlyResource.php | 63 + .../ApiResource/JsonLd/JsonLdContextDummy.php | 70 + .../JsonLd/JsonLdContextRelation.php | 37 + .../JsonLd/JsonSerializableResource.php | 103 ++ .../ApiResource/JsonLd/MaxDepthResource.php | 58 + .../ApiResource/JsonLd/NetworkPathParent.php | 57 + .../JsonLd/NetworkPathResource.php | 78 + .../ApiResource/JsonLd/NoInputResource.php | 81 + .../ApiResource/JsonLd/NoOutputMessage.php | 40 + .../JsonLd/NonRelationResource.php | 58 + .../JsonLd/NonResourceContainer.php | 72 + .../ApiResource/JsonLd/PaginationCapped.php | 43 + .../JsonLd/PlainObjectResource.php | 58 + .../JsonLd/PostNoOutputResource.php | 39 + .../RenamedGetterSetter.php} | 19 +- .../ApiResource/JsonLd/UriTemplateCar.php | 85 + .../Document/JsonldContextDummy.php | 56 - .../Document/MaxDepthEagerDummy.php | 43 - .../Document/RelatedLinkedDummy.php | 5 +- .../TestBundle/Entity/DummyProblem.php | 1 - .../TestBundle/Entity/JsonldContextDummy.php | 58 - .../TestBundle/Entity/MaxDepthEagerDummy.php | 45 - .../TestBundle/Entity/RelatedLinkedDummy.php | 5 +- .../State/JsonLdPaginationCappedProvider.php | 39 + tests/Fixtures/app/config/config_common.yml | 6 + tests/Functional/Authorization/DenyTest.php | 573 ++++++ .../Authorization/LegacyDenyTest.php | 159 ++ tests/Functional/Hal/AbsoluteUrlTest.php | 85 + tests/Functional/Hal/CollectionTest.php | 167 ++ tests/Functional/Hal/HalTest.php | 120 ++ tests/Functional/Hal/InputOutputDtoTest.php | 56 + tests/Functional/Hal/ItemUriTemplateTest.php | 69 + tests/Functional/Hal/MaxDepthTest.php | 94 + tests/Functional/Hal/NetworkPathTest.php | 84 + tests/Functional/Hal/NonResourceTest.php | 50 + tests/Functional/Hal/ProblemTest.php | 77 + .../Hal/PropertyCollectionIriOnlyTest.php | 89 + tests/Functional/Hal/TableInheritanceTest.php | 165 ++ tests/Functional/JsonApi/AbsoluteUrlTest.php | 100 + .../JsonApi/CollectionAttributesTest.php | 47 + .../JsonApi/CollectionUriTemplateTest.php | 108 ++ tests/Functional/JsonApi/CrudTest.php | 362 ++++ tests/Functional/JsonApi/EntrypointTest.php | 56 + tests/Functional/JsonApi/ErrorTest.php | 117 ++ tests/Functional/JsonApi/FilteringTest.php | 89 + .../Functional/JsonApi/IdentifierModeTest.php | 149 ++ tests/Functional/JsonApi/InputDtoTest.php | 100 + tests/Functional/JsonApi/InputOutputTest.php | 65 + tests/Functional/JsonApi/IriModeTest.php | 52 + .../JsonApi/ItemUriTemplateTest.php | 98 + tests/Functional/JsonApi/NetworkPathTest.php | 98 + tests/Functional/JsonApi/NonResourceTest.php | 132 ++ tests/Functional/JsonApi/OrderingTest.php | 63 + tests/Functional/JsonApi/PaginationTest.php | 83 + .../JsonApi/RelatedResourcesInclusionTest.php | 591 ++++++ tests/Functional/JsonApiTest.php | 270 --- .../JsonLd/AbsolutePaginationTest.php | 48 + tests/Functional/JsonLd/AbsoluteUrlTest.php | 89 + tests/Functional/JsonLd/ContextOutputTest.php | 51 + tests/Functional/JsonLd/ContextTest.php | 115 ++ .../JsonLd/CursorPaginationTest.php | 104 ++ .../JsonLd/DisableIdGenerationTest.php | 41 + .../JsonLd/EntityClassWithDateTimeTest.php | 59 + tests/Functional/JsonLd/EntrypointTest.php | 46 + tests/Functional/JsonLd/GenIdFalseTest.php | 53 + .../Functional/JsonLd/HydraCollectionTest.php | 205 +++ tests/Functional/JsonLd/HydraDocsTest.php | 210 +++ tests/Functional/JsonLd/HydraErrorTest.php | 158 ++ .../HydraHideFromDocsTest.php} | 9 +- .../Functional/JsonLd/InheritanceIriTest.php | 64 + .../Functional/JsonLd/InitializeInputTest.php | 65 + .../JsonLd/InputDtoIriDenormalizationTest.php | 69 + .../Functional/JsonLd/InputOutputDtoTest.php | 277 +++ .../JsonLd/InterfaceAsResourceTest.php | 60 + .../JsonLd/InterfaceDtoOutputTest.php | 44 + tests/Functional/JsonLd/IriOnlyTest.php | 80 + .../JsonLd/ItemUriTemplateCollectionTest.php | 112 ++ .../JsonLd/ItemUriTemplateHydraTest.php | 176 ++ .../ItemUriTemplateNotFoundTest.php} | 6 +- .../JsonLd/JsonSerializableTest.php | 67 + .../{ => JsonLd}/LinkedDataPlatformTest.php | 2 +- tests/Functional/JsonLd/MaxDepthTest.php | 69 + tests/Functional/JsonLd/MessengerTest.php | 73 + .../JsonLd/MultiResourceContextTest.php | 67 + tests/Functional/JsonLd/NetworkPathTest.php | 104 ++ tests/Functional/JsonLd/NoOutputTest.php | 44 + tests/Functional/JsonLd/NonResourceTest.php | 152 ++ .../PolymorphicResourceCollectionTest.php | 55 + .../JsonLd/PropertyCollectionIriOnlyTest.php | 98 + .../JsonLd/RenamedGetterSetterTest.php | 49 + tests/Functional/JsonLdTest.php | 283 --- 191 files changed, 11410 insertions(+), 8130 deletions(-) delete mode 100644 features/authorization/deny.feature delete mode 100644 features/authorization/legacy_deny.feature delete mode 100644 features/hal/absolute_url.feature delete mode 100644 features/hal/collection.feature delete mode 100644 features/hal/collection_uri_template.feature delete mode 100644 features/hal/hal.feature delete mode 100644 features/hal/input_output.feature delete mode 100644 features/hal/item_uri_template.feature delete mode 100644 features/hal/max_depth.feature delete mode 100644 features/hal/network_path.feature delete mode 100644 features/hal/non_resource.feature delete mode 100644 features/hal/problem.feature delete mode 100644 features/hal/table_inheritance.feature delete mode 100644 features/hydra/absolute.feature delete mode 100644 features/hydra/collection.feature delete mode 100644 features/hydra/docs.feature delete mode 100644 features/hydra/entrypoint.feature delete mode 100644 features/hydra/error.feature delete mode 100644 features/hydra/item_uri_template.feature delete mode 100644 features/jsonapi/absolute_url.feature delete mode 100644 features/jsonapi/collection_attributes.feature delete mode 100644 features/jsonapi/collection_uri_template.feature delete mode 100644 features/jsonapi/errors.feature delete mode 100644 features/jsonapi/filtering.feature delete mode 100644 features/jsonapi/input_output.feature delete mode 100644 features/jsonapi/item_uri_template.feature delete mode 100644 features/jsonapi/jsonapi.feature delete mode 100644 features/jsonapi/network_path.feature delete mode 100644 features/jsonapi/non_resource.feature delete mode 100644 features/jsonapi/ordering.feature delete mode 100644 features/jsonapi/pagination.feature delete mode 100644 features/jsonapi/related-resouces-inclusion.feature delete mode 100644 features/jsonld/absolute_url.feature delete mode 100644 features/jsonld/context.feature delete mode 100644 features/jsonld/disable_id_generation.feature delete mode 100644 features/jsonld/getter_setter_renaming.feature delete mode 100644 features/jsonld/inheritance.feature delete mode 100644 features/jsonld/input_output.feature delete mode 100644 features/jsonld/interface_as_resource.feature delete mode 100644 features/jsonld/interface_dto_output.feature delete mode 100644 features/jsonld/iri_only.feature delete mode 100644 features/jsonld/json_serializable.feature delete mode 100644 features/jsonld/max_depth.feature delete mode 100644 features/jsonld/network_path.feature delete mode 100644 features/jsonld/no_output.feature delete mode 100644 features/jsonld/non_resource.feature create mode 100644 tests/Fixtures/TestBundle/ApiResource/DtoOutput.php delete mode 100644 tests/Fixtures/TestBundle/ApiResource/EntityWithDtoOutput.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/CollectionPagedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/CustomOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/HalRelatedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/HalThirdLevel.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/MaxDepthResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/NonResourceContainer.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/ProblemRelation.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/ProblemResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/RelationEmbedder.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/UriTemplateCar.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlRelationDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/CircularReference.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/CustomOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/EntrypointDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringProperty.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathRelationDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NonRelationResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NonResourceContainer.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/OrderingDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/PaginationDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/PlainObjectResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/UriTemplateCar.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsolutePagedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionNoPrefix.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionPagedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomInputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DateTimeOnlyResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DisableIdGenAnonymous.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyFooCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyIdCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/GenIdFalseProperty.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsDeprecated.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsRelated.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraErrorResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/InputOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceDtoOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxon.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxonProduct.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/IriOnlyResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextRelation.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonSerializableResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/MaxDepthResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NoInputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NoOutputMessage.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NonRelationResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/PaginationCapped.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/PlainObjectResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/PostNoOutputResource.php rename tests/Fixtures/TestBundle/ApiResource/{EntityWithRenamedGetterAndSetter.php => JsonLd/RenamedGetterSetter.php} (63%) create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/UriTemplateCar.php delete mode 100644 tests/Fixtures/TestBundle/Document/JsonldContextDummy.php delete mode 100644 tests/Fixtures/TestBundle/Document/MaxDepthEagerDummy.php delete mode 100644 tests/Fixtures/TestBundle/Entity/JsonldContextDummy.php delete mode 100644 tests/Fixtures/TestBundle/Entity/MaxDepthEagerDummy.php create mode 100644 tests/Fixtures/TestBundle/State/JsonLdPaginationCappedProvider.php create mode 100644 tests/Functional/Authorization/DenyTest.php create mode 100644 tests/Functional/Authorization/LegacyDenyTest.php create mode 100644 tests/Functional/Hal/AbsoluteUrlTest.php create mode 100644 tests/Functional/Hal/CollectionTest.php create mode 100644 tests/Functional/Hal/HalTest.php create mode 100644 tests/Functional/Hal/InputOutputDtoTest.php create mode 100644 tests/Functional/Hal/ItemUriTemplateTest.php create mode 100644 tests/Functional/Hal/MaxDepthTest.php create mode 100644 tests/Functional/Hal/NetworkPathTest.php create mode 100644 tests/Functional/Hal/NonResourceTest.php create mode 100644 tests/Functional/Hal/ProblemTest.php create mode 100644 tests/Functional/Hal/PropertyCollectionIriOnlyTest.php create mode 100644 tests/Functional/Hal/TableInheritanceTest.php create mode 100644 tests/Functional/JsonApi/AbsoluteUrlTest.php create mode 100644 tests/Functional/JsonApi/CollectionAttributesTest.php create mode 100644 tests/Functional/JsonApi/CollectionUriTemplateTest.php create mode 100644 tests/Functional/JsonApi/CrudTest.php create mode 100644 tests/Functional/JsonApi/EntrypointTest.php create mode 100644 tests/Functional/JsonApi/ErrorTest.php create mode 100644 tests/Functional/JsonApi/FilteringTest.php create mode 100644 tests/Functional/JsonApi/IdentifierModeTest.php create mode 100644 tests/Functional/JsonApi/InputDtoTest.php create mode 100644 tests/Functional/JsonApi/InputOutputTest.php create mode 100644 tests/Functional/JsonApi/IriModeTest.php create mode 100644 tests/Functional/JsonApi/ItemUriTemplateTest.php create mode 100644 tests/Functional/JsonApi/NetworkPathTest.php create mode 100644 tests/Functional/JsonApi/NonResourceTest.php create mode 100644 tests/Functional/JsonApi/OrderingTest.php create mode 100644 tests/Functional/JsonApi/PaginationTest.php create mode 100644 tests/Functional/JsonApi/RelatedResourcesInclusionTest.php delete mode 100644 tests/Functional/JsonApiTest.php create mode 100644 tests/Functional/JsonLd/AbsolutePaginationTest.php create mode 100644 tests/Functional/JsonLd/AbsoluteUrlTest.php create mode 100644 tests/Functional/JsonLd/ContextOutputTest.php create mode 100644 tests/Functional/JsonLd/ContextTest.php create mode 100644 tests/Functional/JsonLd/CursorPaginationTest.php create mode 100644 tests/Functional/JsonLd/DisableIdGenerationTest.php create mode 100644 tests/Functional/JsonLd/EntityClassWithDateTimeTest.php create mode 100644 tests/Functional/JsonLd/EntrypointTest.php create mode 100644 tests/Functional/JsonLd/GenIdFalseTest.php create mode 100644 tests/Functional/JsonLd/HydraCollectionTest.php create mode 100644 tests/Functional/JsonLd/HydraDocsTest.php create mode 100644 tests/Functional/JsonLd/HydraErrorTest.php rename tests/Functional/{HydraTest.php => JsonLd/HydraHideFromDocsTest.php} (88%) create mode 100644 tests/Functional/JsonLd/InheritanceIriTest.php create mode 100644 tests/Functional/JsonLd/InitializeInputTest.php create mode 100644 tests/Functional/JsonLd/InputDtoIriDenormalizationTest.php create mode 100644 tests/Functional/JsonLd/InputOutputDtoTest.php create mode 100644 tests/Functional/JsonLd/InterfaceAsResourceTest.php create mode 100644 tests/Functional/JsonLd/InterfaceDtoOutputTest.php create mode 100644 tests/Functional/JsonLd/IriOnlyTest.php create mode 100644 tests/Functional/JsonLd/ItemUriTemplateCollectionTest.php create mode 100644 tests/Functional/JsonLd/ItemUriTemplateHydraTest.php rename tests/Functional/{ItemUriTemplateTest.php => JsonLd/ItemUriTemplateNotFoundTest.php} (84%) create mode 100644 tests/Functional/JsonLd/JsonSerializableTest.php rename tests/Functional/{ => JsonLd}/LinkedDataPlatformTest.php (98%) create mode 100644 tests/Functional/JsonLd/MaxDepthTest.php create mode 100644 tests/Functional/JsonLd/MessengerTest.php create mode 100644 tests/Functional/JsonLd/MultiResourceContextTest.php create mode 100644 tests/Functional/JsonLd/NetworkPathTest.php create mode 100644 tests/Functional/JsonLd/NoOutputTest.php create mode 100644 tests/Functional/JsonLd/NonResourceTest.php create mode 100644 tests/Functional/JsonLd/PolymorphicResourceCollectionTest.php create mode 100644 tests/Functional/JsonLd/PropertyCollectionIriOnlyTest.php create mode 100644 tests/Functional/JsonLd/RenamedGetterSetterTest.php delete mode 100644 tests/Functional/JsonLdTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff108304e10..2f4faca56e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,6 @@ jobs: php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} shard: - main - - ld-api-hal-hydra - graphql-doctrine - misc include: @@ -496,9 +495,8 @@ jobs: run: | case "${{ matrix.shard }}" in main) paths="features/main" ;; - ld-api-hal-hydra) paths="features/jsonld features/jsonapi features/hal features/hydra" ;; graphql-doctrine) paths="features/graphql features/doctrine" ;; - misc) paths="features/authorization features/filter features/issues features/security features/serializer features/http_cache features/sub_resources features/json features/xml features/push_relations features/mercure" ;; + misc) paths="features/filter features/issues features/security features/serializer features/http_cache features/sub_resources features/json features/xml features/push_relations features/mercure" ;; esac echo "paths=$paths" >> $GITHUB_OUTPUT - name: Run Behat tests (PHP ${{ matrix.php }} ${{ matrix.shard }}) diff --git a/features/authorization/deny.feature b/features/authorization/deny.feature deleted file mode 100644 index 8aeac46c7bd..00000000000 --- a/features/authorization/deny.feature +++ /dev/null @@ -1,319 +0,0 @@ -Feature: Authorization checking - In order to use the API - As a client software user - I need to be authorized to access a given resource. - - @createSchema - Scenario: An anonymous user retrieves a secured resource - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 401 - - Scenario: An authenticated user retrieve a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should be in JSON - - Scenario: Data provider that's return generator has null previous object - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/custom_data_provider_generator" - Then the response status code should be 200 - - Scenario: A standard user cannot create a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "POST" request to "/secured_dummies" with body: - """ - { - "title": "Title", - "description": "Description", - "owner": "foo" - } - """ - Then the response status code should be 403 - - Scenario: An admin can create a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/secured_dummies" with body: - """ - { - "title": "Title", - "description": "Description", - "owner": "someone" - } - """ - Then the response status code should be 201 - - Scenario: An admin can create another secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/secured_dummies" with body: - """ - { - "title": "Special Title", - "description": "Description", - "owner": "dunglas", - "adminOnlyProperty": "secret" - } - """ - Then the response status code should be 201 - - Scenario: A user cannot retrieve an item they doesn't own - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/1" - Then the response status code should be 403 - And the response should be in JSON - - Scenario: A user can retrieve an item they owns - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/2" - Then the response status code should be 200 - - Scenario: A user can see a secured owner-only property, or accessible property based on voter, on an object they own - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/2" - Then the response status code should be 200 - And the JSON node "ownerOnlyProperty" should exist - And the JSON node "ownerOnlyProperty" should not be null - And the JSON node "attributeBasedProperty" should exist - And the JSON node "attributeBasedProperty" should not be null - - @!mongodb - Scenario: An admin can create a secured resource with properties depending on themselves - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/secured_dummy_with_properties_depending_on_themselves" with body: - """ - { - "canUpdateProperty": false, - "property": false - } - """ - Then the response status code should be 201 - - @!mongodb - Scenario: A user cannot patch a secured property if not granted - When I add "Content-Type" header equal to "application/merge-patch+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "PATCH" request to "/secured_dummy_with_properties_depending_on_themselves/1" with body: - """ - { - "canUpdateProperty": true, - "property": true - } - """ - Then the response status code should be 200 - And the JSON node "canUpdateProperty" should be true - And the JSON node "property" should be false - - Scenario: An admin can't see a secured owner-only property, or non-accessible property based on voter, on objects they don't own - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should not contain "ownerOnlyProperty" - And the response should not contain "attributeBasedProperty" - - Scenario: A user can't assign to themself an item they doesn't own - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "PUT" request to "/secured_dummies/2" with body: - """ - { - "owner": "kitten" - } - """ - Then the response status code should be 403 - - Scenario: A user can update an item they owns and transfer it - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "PUT" request to "/secured_dummies/2" with body: - """ - { - "owner": "vincent" - } - """ - Then the response status code should be 200 - - Scenario: An admin retrieves a resource with an admin only viewable property - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should contain "adminOnlyProperty" - - Scenario: A user retrieves a resource with an admin only viewable property - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should not contain "adminOnlyProperty" - - Scenario: An admin can create a secured resource with a secured Property - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/secured_dummies" with body: - """ - { - "title": "Common Title", - "description": "Description", - "owner": "dunglas", - "adminOnlyProperty": "Is it safe?" - } - """ - Then the response status code should be 201 - And the response should contain "adminOnlyProperty" - And the JSON node "adminOnlyProperty" should be equal to the string "Is it safe?" - - Scenario: A user cannot update a secured property - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "PUT" request to "/secured_dummies/3" with body: - """ - { - "adminOnlyProperty": "Yes it is!" - } - """ - Then the response status code should be 200 - And the response should not contain "adminOnlyProperty" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should contain "adminOnlyProperty" - And the JSON node "hydra:member[2].adminOnlyProperty" should be equal to the string "Is it safe?" - - Scenario: An user can update owner-only secured or accessible properties on an object they own - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "PUT" request to "/secured_dummies/3" with body: - """ - { - "ownerOnlyProperty": "updated", - "attributeBasedProperty": "updated" - } - """ - Then the response status code should be 200 - And the response should contain "ownerOnlyProperty" - And the JSON node "ownerOnlyProperty" should be equal to the string "updated" - And the JSON node "attributeBasedProperty" should be equal to the string "updated" - - @link_security - Scenario: An non existing entity should return Not found - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/40000/to_from" - Then the response status code should be 404 - - @link_security - Scenario: An user can get related linked dummies for an secured dummy they own - Given there are 1 SecuredDummy objects owned by dunglas with related dummies - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/4/to_from" - Then the response status code should be 200 - And the response should contain "securedDummy" - And the JSON node "hydra:member[0].id" should be equal to 1 - - @link_security - Scenario: I define a custom name of the security object - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/4/with_name" - Then the response status code should be 200 - And the response should contain "securedDummy" - And the JSON node "hydra:member[0].id" should be equal to 1 - - @link_security - Scenario: I define a from from link - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/related_linked_dummies/1/from_from" - Then the response status code should be 200 - And the response should contain "id" - And the JSON node "hydra:member[0].id" should be equal to 4 - - @link_security - Scenario: I define multiple links with security - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/4/related/1" - Then the response status code should be 200 - And the response should contain "id" - And the JSON node "hydra:member[0].id" should be equal to 1 - - @link_security - Scenario: An user can not get related linked dummies for an secured dummy they do not own - Given there are 1 SecuredDummy objects owned by someone with related dummies - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/5/to_from" - Then the response status code should be 403 - - @link_security - Scenario: I define a custom name of the security object - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/5/with_name" - Then the response status code should be 403 - - @link_security - Scenario: I define a from from link - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/related_linked_dummies/2/from_from" - Then the response status code should be 403 - - @link_security - Scenario: I define multiple links with security - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/5/related/2" - Then the response status code should be 403 - - Scenario: A user retrieves a resource with an admin only viewable property - When I add "Accept" header equal to "application/json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should contain "ownerOnlyProperty" - And the response should contain "attributeBasedProperty" - - Scenario: Security post validation should be hit - When I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "POST" request to "/issue_6446" with body: - """ - { - "title": "" - } - """ - Then the response status code should be 403 - diff --git a/features/authorization/legacy_deny.feature b/features/authorization/legacy_deny.feature deleted file mode 100644 index b911022c3bd..00000000000 --- a/features/authorization/legacy_deny.feature +++ /dev/null @@ -1,96 +0,0 @@ -Feature: Authorization checking - In order to use the API - As a client software user - I need to be authorized to access a given resource using legacy access_control attribute. - - @createSchema - Scenario: An anonymous user retrieves a secured resource - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/legacy_secured_dummies" - Then the response status code should be 401 - - Scenario: An authenticated user retrieve a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/legacy_secured_dummies" - Then the response status code should be 200 - And the response should be in JSON - - Scenario: A standard user cannot create a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "POST" request to "/legacy_secured_dummies" with body: - """ - { - "title": "Title", - "description": "Description", - "owner": "foo" - } - """ - Then the response status code should be 403 - - Scenario: An admin can create a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/legacy_secured_dummies" with body: - """ - { - "title": "Title", - "description": "Description", - "owner": "someone" - } - """ - Then the response status code should be 201 - - Scenario: An admin can create another secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/legacy_secured_dummies" with body: - """ - { - "title": "Special Title", - "description": "Description", - "owner": "dunglas" - } - """ - Then the response status code should be 201 - - Scenario: A user cannot retrieve an item they doesn't own - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/legacy_secured_dummies/1" - Then the response status code should be 403 - And the response should be in JSON - - Scenario: A user can retrieve an item they owns - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/legacy_secured_dummies/2" - Then the response status code should be 200 - - Scenario: A user can't assign to themself an item they doesn't own - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "PUT" request to "/legacy_secured_dummies/2" with body: - """ - { - "owner": "kitten" - } - """ - Then the response status code should be 403 - - Scenario: A user can update an item they owns and transfer it - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "PUT" request to "/legacy_secured_dummies/2" with body: - """ - { - "owner": "vincent" - } - """ - Then the response status code should be 200 diff --git a/features/hal/absolute_url.feature b/features/hal/absolute_url.feature deleted file mode 100644 index 87be61f0670..00000000000 --- a/features/hal/absolute_url.feature +++ /dev/null @@ -1,119 +0,0 @@ -Feature: IRI should contain Absolute URL - In order to add detail to IRIs - Include the absolute url - - @createSchema - Scenario: I should be able to GET a collection of Objects with Absolute Urls - Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy - And I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_dummies" - }, - "item": [ - { - "href": "http://example.com/absolute_url_dummies/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_dummies/1" - }, - "absoluteUrlRelationDummy": { - "href": "http://example.com/absolute_url_relation_dummies/1" - } - }, - "id": 1 - } - ] - } - } - """ - - Scenario: I should be able to POST an object using an Absolute Url - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/absolute_url_relation_dummies" with body: - """ - { - "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_relation_dummies/2" - } - }, - "id": 2 - } - """ - - Scenario: I should be able to GET an Item with Absolute Urls - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/absolute_url_dummies/1" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_dummies/1" - }, - "absoluteUrlRelationDummy": { - "href": "http://example.com/absolute_url_relation_dummies/1" - } - }, - "id": 1 - } - """ - - Scenario: I should be able to GET resources with Absolute Urls - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies" - }, - "item": [ - { - "href": "http://example.com/absolute_url_dummies/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_dummies/1" - }, - "absoluteUrlRelationDummy": { - "href": "http://example.com/absolute_url_relation_dummies/1" - } - }, - "id": 1 - } - ] - } - } - """ diff --git a/features/hal/collection.feature b/features/hal/collection.feature deleted file mode 100644 index 8507c886621..00000000000 --- a/features/hal/collection.feature +++ /dev/null @@ -1,637 +0,0 @@ -Feature: HAL Collections support - In order to retrieve large collections of resources - As a client software developer - I need to retrieve paged collections respecting the HAL specification - - @createSchema - Scenario: Retrieve an empty collection - When I add "Accept" header equal to "application/hal+json" - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies" - } - }, - "totalItems": 0, - "itemsPerPage": 3 - } - """ - - Scenario: Retrieve the first page of a collection - Given there are 10 dummy objects - And I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?page=1" - }, - "first": { - "href": "/dummies?page=1" - }, - "last": { - "href": "/dummies?page=4" - }, - "next": { - "href": "/dummies?page=2" - }, - "item": [ - { - "href": "/dummies/1" - }, - { - "href": "/dummies/2" - }, - { - "href": "/dummies/3" - } - ] - }, - "totalItems": 10, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/1" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest1", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 1", - "id": 1, - "name": "Dummy #1", - "alias": "Alias #9", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/2" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest2", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 2", - "id": 2, - "name": "Dummy #2", - "alias": "Alias #8", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/3" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest3", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 3", - "id": 3, - "name": "Dummy #3", - "alias": "Alias #7", - "foo": null - } - ] - } - } - """ - - Scenario: Retrieve a page of a collection - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?page=3" - }, - "first": { - "href": "/dummies?page=1" - }, - "last": { - "href": "/dummies?page=4" - }, - "prev": { - "href": "/dummies?page=2" - }, - "next": { - "href": "/dummies?page=4" - }, - "item": [ - { - "href": "/dummies/7" - }, - { - "href": "/dummies/8" - }, - { - "href": "/dummies/9" - } - ] - }, - "totalItems": 10, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/7" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest7", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 7", - "id": 7, - "name": "Dummy #7", - "alias": "Alias #3", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/8" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest8", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 8", - "id": 8, - "name": "Dummy #8", - "alias": "Alias #2", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/9" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest9", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 9", - "id": 9, - "name": "Dummy #9", - "alias": "Alias #1", - "foo": null - } - ] - } - } - """ - - Scenario: Retrieve the last page of a collection - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?page=4" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?page=4" - }, - "first": { - "href": "/dummies?page=1" - }, - "last": { - "href": "/dummies?page=4" - }, - "prev": { - "href": "/dummies?page=3" - }, - "item": [ - { - "href": "/dummies/10" - } - ] - }, - "totalItems": 10, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/10" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest10", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 10", - "id": 10, - "name": "Dummy #10", - "alias": "Alias #0", - "foo": null - } - ] - } - } - """ - - @!mongodb - Scenario: Enable the partial pagination client side - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?page=2&partial=1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?partial=1&page=2" - }, - "prev": { - "href": "/dummies?partial=1&page=1" - }, - "next": { - "href": "/dummies?partial=1&page=3" - }, - "item": [ - { - "href": "/dummies/4" - }, - { - "href": "/dummies/5" - }, - { - "href": "/dummies/6" - } - ] - }, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/4" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest4", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 4", - "id": 4, - "name": "Dummy #4", - "alias": "Alias #6", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/5" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest5", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 5", - "id": 5, - "name": "Dummy #5", - "alias": "Alias #5", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/6" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest6", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 6", - "id": 6, - "name": "Dummy #6", - "alias": "Alias #4", - "foo": null - } - ] - } - } - """ - - Scenario: Disable the pagination client side - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?pagination=0" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/dummies\\?pagination=0$"}} - }, - "item": { - "type": "array", - "minItems": 10, - "maxItems": 10, - "items": { - "type": "object", - "properties": {"href": {"pattern": "^/dummies/[0-9]+$"}} - } - } - } - }, - "totalItems": {"type":"number", "minimum": 10, "maximum": 10}, - "_embedded": { - "type": "object", - "properties": { - "item": { - "type": "array", - "minItems": 10, - "maxItems": 10, - "items": { - "type": "object", - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/dummies/[0-9]+$"}} - } - } - }, - "description": {"pattern": "(Smart dummy.|Not so smart dummy.)"} - } - } - } - } - } - }, - "additionalProperties": false - } - """ - - Scenario: Change the number of element by page client side - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?page=2&itemsPerPage=1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?itemsPerPage=1&page=2" - }, - "first": { - "href": "/dummies?itemsPerPage=1&page=1" - }, - "last": { - "href": "/dummies?itemsPerPage=1&page=10" - }, - "prev": { - "href": "/dummies?itemsPerPage=1&page=1" - }, - "next": { - "href": "/dummies?itemsPerPage=1&page=3" - }, - "item": [ - { - "href": "/dummies/2" - } - ] - }, - "totalItems": 10, - "itemsPerPage": 1, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/2" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest2", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 2", - "id": 2, - "name": "Dummy #2", - "alias": "Alias #8", - "foo": null - } - ] - } - } - """ - - Scenario: Filter with a raw URL - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?id=%2fdummies%2f8" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?id=%2Fdummies%2F8" - }, - "item": [ - { - "href": "/dummies/8" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/8" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest8", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 8", - "id": 8, - "name": "Dummy #8", - "alias": "Alias #2", - "foo": null - } - ] - } -} - """ - - Scenario: Filter with non-exact match - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?name=Dummy%20%238" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?name=Dummy%20%238" - }, - "item": [ - { - "href": "/dummies/8" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/8" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest8", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 8", - "id": 8, - "name": "Dummy #8", - "alias": "Alias #2", - "foo": null - } - ] - } - } - """ - - @!mongodb - Scenario: Allow passing 0 to `itemsPerPage` - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?itemsPerPage=0" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links":{ - "self":{ - "href":"/dummies?itemsPerPage=0" - } - }, - "totalItems":10, - "itemsPerPage":0 - } - """ diff --git a/features/hal/collection_uri_template.feature b/features/hal/collection_uri_template.feature deleted file mode 100644 index a71c2d9329c..00000000000 --- a/features/hal/collection_uri_template.feature +++ /dev/null @@ -1,81 +0,0 @@ -@php8 -@v3 -Feature: Exposing a property being a collection of resources - can return an IRI instead of an array - when the uriTemplate is set on the ApiProperty attribute - - @createSchema - Scenario: Retrieve Resource with uriTemplate collection Property - Given there are propertyCollectionIriOnly with relations - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/property_collection_iri_onlies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/property_collection_iri_onlies/1" - }, - "propertyCollectionIriOnlyRelation": { - "href": "/property-collection-relations" - }, - "iterableIri": { - "href": "/parent/1/another-collection-operations" - }, - "toOneRelation": { - "href": "/parent/1/property-uri-template/one-to-ones/1" - } - }, - "_embedded": { - "propertyCollectionIriOnlyRelation": [ - { - "_links": { - "self": { - "href": "/property_collection_iri_only_relations/1" - }, - "children": { - "href": "/property_collection_iri_only_relations/1/children" - } - }, - "name": "asb1" - }, - { - "_links": { - "self": { - "href": "/property_collection_iri_only_relations/2" - }, - "children": { - "href": "/property_collection_iri_only_relations/2/children" - } - }, - "name": "asb2" - } - ], - "iterableIri": [ - { - "_links": { - "self": { - "href": "/property_collection_iri_only_relations/9999" - }, - "children": { - "href": "/property_collection_iri_only_relations/9999/children" - } - }, - "name": "Michel" - } - ], - "toOneRelation": { - "_links": { - "self": { - "href": "/parent/1/property-uri-template/one-to-ones/1" - } - }, - "name": "xarguš" - } - } - } - """ diff --git a/features/hal/hal.feature b/features/hal/hal.feature deleted file mode 100644 index be469651f58..00000000000 --- a/features/hal/hal.feature +++ /dev/null @@ -1,224 +0,0 @@ -Feature: HAL support - In order to use the HAL hypermedia format - As a client software developer - I need to be able to retrieve valid HAL responses. - - @createSchema - Scenario: Retrieve the API entrypoint - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON node "_links.self.href" should be equal to "/" - And the JSON node "_links.dummy.href" should be equal to "/dummies" - - 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 - - Scenario: Create a related dummy - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/related_dummies" with body: - """ - {"thirdLevel": "/third_levels/1"} - """ - Then the response status code should be 201 - - Scenario: Create a dummy with relations - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Dummy with relations", - "dummyDate": "2015-03-01T10:00:00+00:00", - "relatedDummy": "http://example.com/related_dummies/1", - "relatedDummies": [ - "/related_dummies/1" - ] - } - """ - Then the response status code should be 201 - - Scenario: Get a resource with relations - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies/1" - }, - "relatedDummy": { - "href": "/related_dummies/1" - }, - "relatedDummies": [ - { - "href": "/related_dummies/1" - } - ] - }, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null - } - """ - - Scenario: Update a resource (legacy PUT as standard_put: false) - When I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "/dummies/1" with body: - """ - {"name": "A nice dummy"} - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies/1" - }, - "relatedDummy": { - "href": "/related_dummies/1" - }, - "relatedDummies": [ - { - "href": "/related_dummies/1" - } - ] - }, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "id": 1, - "name": "A nice dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Update a resource - When I add "Accept" header equal to "application/hal+json" - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/dummies/1" with body: - """ - {"name": "A nice dummy"} - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies/1" - }, - "relatedDummy": { - "href": "/related_dummies/1" - }, - "relatedDummies": [ - { - "href": "/related_dummies/1" - } - ] - }, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "id": 1, - "name": "A nice dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Embed a relation in a parent object - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": "/related_dummies/1" - } - """ - Then the response status code should be 201 - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/relation_embedders/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/relation_embedders/1" - }, - "related": { - "href": "/related_dummies/1" - } - }, - "_embedded": { - "related": { - "_links": { - "self": { - "href": "/related_dummies/1" - }, - "thirdLevel": { - "href": "/third_levels/1" - } - }, - "_embedded": { - "thirdLevel": { - "_links": { - "self": { - "href": "/third_levels/1" - } - }, - "level": 3 - } - }, - "symfony": "symfony" - } - }, - "krondstadt": "Krondstadt" - } - """ diff --git a/features/hal/input_output.feature b/features/hal/input_output.feature deleted file mode 100644 index c7acbe989f0..00000000000 --- a/features/hal/input_output.feature +++ /dev/null @@ -1,48 +0,0 @@ -Feature: HAL DTO input and output - In order to use a hypermedia API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - for the collection we can search for an Operation with the same Output class as the given one for the collection - - Background: - Given I add "Accept" header equal to "application/hal+json" - - @createSchema - Scenario: Get an item with a custom output - Given there is a DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output/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" - And the JSON should be a superset of: - """ - { - "foo": "test", - "bar": 1 - } - """ - - @createSchema - Scenario: Get a collection with a custom output - Given there are 2 DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output" - 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" - And the JSON should be a superset of: - """ - { - "_embedded": { - "item": [ - { - "foo": "test", - "bar": 1 - }, - { - "foo": "test", - "bar": 2 - } - ] - } - } - """ diff --git a/features/hal/item_uri_template.feature b/features/hal/item_uri_template.feature deleted file mode 100644 index 5c949d07161..00000000000 --- a/features/hal/item_uri_template.feature +++ /dev/null @@ -1,128 +0,0 @@ -@php8 -@v3 -Feature: Exposing a collection of objects should use the specified operation to generate the IRI - - Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/cars" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["_links", "_embedded", "totalItems"], - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/cars$"}} - }, - "item": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "object", - "properties": {"href": {"pattern": "^/cars/.+$"}} - } - } - } - }, - "totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "_embedded": { - "type": "object", - "properties": { - "item": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "object", - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/cars/.+$"}} - } - } - }, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - } - """ - - Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/brands/renault/cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["_links", "_embedded", "totalItems"], - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/brands/renault/cars$"}} - }, - "item": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "object", - "properties": {"href": {"pattern": "^/brands/renault/cars/.+$"}} - } - } - } - }, - "totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "_embedded": { - "type": "object", - "properties": { - "item": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "object", - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/brands/renault/cars/.+$"}} - } - } - }, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - } - """ diff --git a/features/hal/max_depth.feature b/features/hal/max_depth.feature deleted file mode 100644 index ef1b9601dd7..00000000000 --- a/features/hal/max_depth.feature +++ /dev/null @@ -1,69 +0,0 @@ -Feature: Max depth handling - In order to handle MaxChildDepth resources - As a developer - I need to be able to limit their depth with @maxDepth - - @createSchema - Scenario: Create a resource with 1 level of descendants - When I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/max_depth_eager_dummies" with body: - """ - { - "name": "level 1", - "child": { - "name": "level 2" - } - } - """ - 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/hal+json; charset=utf-8" - Then the JSON node "_embedded" should exist - Then the JSON node "_embedded.child" should exist - Then the JSON node "_embedded.child._embedded" should not exist - - Scenario: Create a resource with 2 levels of descendants - When I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/max_depth_eager_dummies" with body: - """ - { - "name": "level 1", - "child": { - "name": "level 2", - "child": { - "name": "level 3" - } - } - } - """ - And the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - Then the JSON node "_embedded" should exist - Then the JSON node "_embedded.child" should exist - Then the JSON node "_embedded.child._embedded" should not exist - - Scenario: Create a resource with 1 levels of descendants then add a 2nd level of descendants when eager fetching is disabled - Given there is a max depth dummy with 1 level of descendants - When I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "max_depth_dummies/1" with body: - """ - { - "id": "/max_depth_dummies/1", - "child": { - "id": "/max_depth_dummies/2", - "child": { - "name": "level 3" - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - Then the JSON node "_embedded" should exist - Then the JSON node "_embedded.child" should exist - Then the JSON node "_embedded.child._embedded" should not exist diff --git a/features/hal/network_path.feature b/features/hal/network_path.feature deleted file mode 100644 index 264d2a49f82..00000000000 --- a/features/hal/network_path.feature +++ /dev/null @@ -1,117 +0,0 @@ -Feature: IRI should contain network path - In order to add detail to IRIs - Include the network path - - @createSchema - Scenario: I should be able to GET a collection of objects with network paths - Given there are 1 networkPathDummy objects with a related networkPathRelationDummy - And I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/network_path_dummies" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "//example.com/network_path_dummies" - }, - "item": [ - { - "href": "//example.com/network_path_dummies/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "//example.com/network_path_dummies/1" - }, - "networkPathRelationDummy": { - "href": "//example.com/network_path_relation_dummies/1" - } - }, - "id": 1 - } - ] - } - } - """ - - Scenario: I should be able to POST an object using a network path - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/network_path_relation_dummies" with body: - """ - { - "network_path_dummies": "//example.com/network_path_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "//example.com/network_path_relation_dummies/2" - } - }, - "id": 2 - } - """ - - Scenario: I should be able to GET an Item with network paths - Given I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/network_path_dummies/1" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "//example.com/network_path_dummies/1" - }, - "networkPathRelationDummy": { - "href": "//example.com/network_path_relation_dummies/1" - } - }, - "id": 1 - } - """ - - Scenario: I should be able to GET resources with network paths - Given I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "//example.com/network_path_relation_dummies/1/network_path_dummies" - }, - "item": [ - { - "href": "//example.com/network_path_dummies/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "//example.com/network_path_dummies/1" - }, - "networkPathRelationDummy": { - "href": "//example.com/network_path_relation_dummies/1" - } - }, - "id": 1 - } - ] - } - } - """ diff --git a/features/hal/non_resource.feature b/features/hal/non_resource.feature deleted file mode 100644 index 320ea122b83..00000000000 --- a/features/hal/non_resource.feature +++ /dev/null @@ -1,45 +0,0 @@ -Feature: HAL non-resource handling - 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/hal+json" - - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/contain_non_resources/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" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/contain_non_resources/1" - }, - "nested": { - "href": "/contain_non_resources/1-nested" - } - }, - "_embedded": { - "nested": { - "_links": { - "self": { - "href": "/contain_non_resources/1-nested" - } - }, - "id": "1-nested", - "notAResource": { - "foo": "f2", - "bar": "b2" - } - } - }, - "id": 1, - "notAResource": { - "foo": "f1", - "bar": "b1" - } - } - """ diff --git a/features/hal/problem.feature b/features/hal/problem.feature deleted file mode 100644 index 692cd0aa105..00000000000 --- a/features/hal/problem.feature +++ /dev/null @@ -1,52 +0,0 @@ -@!mongodb -Feature: Error handling valid according to RFC 7807 (application/problem+json) - In order to be able to handle error client side - As a client software developer - I need to retrieve an RFC 7807 compliant serialization of errors - - Scenario: Get an error - When I add "Content-Type" header equal to "application/json" - And I add "Accept" header equal to "application/json" - And I send a "POST" request to "/dummy_problems" with body: - """ - {} - """ - Then the response status code should be 422 - 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 should be equal to: - """ - { - "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", - "title": "An error occurred", - "detail": "name: This value should not be blank.", - "status": "422", - "violations": [ - { - "propertyPath": "name", - "message": "This value should not be blank.", - "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" - } - ] - } - """ - - Scenario: Get an error during deserialization of simple relation - When I add "Content-Type" header equal to "application/json" - And I add "Accept" header equal to "application/json" - And I send a "POST" request to "/dummy_problems" with body: - """ - { - "name": "Foo", - "relatedDummy": { - "name": "bar" - } - } - """ - Then the response status code should be 400 - 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 "type" should be equal to "/errors/400" - And the JSON node "title" should be equal to "An error occurred" - And the JSON node "detail" should be equal to 'Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.' - And the JSON node "trace" should exist diff --git a/features/hal/table_inheritance.feature b/features/hal/table_inheritance.feature deleted file mode 100644 index 55ef27b7cad..00000000000 --- a/features/hal/table_inheritance.feature +++ /dev/null @@ -1,141 +0,0 @@ -Feature: Table inheritance - In order to use the api with Doctrine table inheritance - As a client software developer - I need to be able to create resources and fetch them on the upper entity - - Background: - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - - @createSchema - Scenario: Create a table inherited resource - And I send a "POST" request to "/dummy_table_inheritance_children" with body: - """ - { - "name": "foo", - "nickname": "bar" - } - """ - 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/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummy_table_inheritance_children/1" - } - }, - "nickname": "bar", - "id": 1, - "name": "foo" - } - """ - - Scenario: Get the parent entity collection - When some dummy table inheritance data but not api resource child are created - When I send a "GET" request to "/dummy_table_inheritances" - 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" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummy_table_inheritances" - }, - "item": [ - { - "href": "/dummy_table_inheritance_children/1" - }, - { - "href": "/dummy_table_inheritances/2" - } - ] - }, - "totalItems": 2, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummy_table_inheritance_children/1" - } - }, - "nickname": "bar", - "id": 1, - "name": "foo" - }, - { - "_links": { - "self": { - "href": "/dummy_table_inheritances/2" - } - }, - "id": 2, - "name": "Foobarbaz inheritance" - } - ] - } - } - """ - - - Scenario: Get related entity with multiple inherited children types - And I send a "POST" request to "/dummy_table_inheritance_relateds" with body: - """ - { - "children": [ - "/dummy_table_inheritance_children/1", - "/dummy_table_inheritances/2" - ] - } - """ - 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/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummy_table_inheritance_relateds/1" - }, - "children": [ - { - "href": "/dummy_table_inheritance_children/1" - }, - { - "href": "/dummy_table_inheritances/2" - } - ] - }, - "_embedded": { - "children": [ - { - "_links": { - "self": { - "href": "/dummy_table_inheritance_children/1" - } - }, - "nickname": "bar", - "id": 1, - "name": "foo" - }, - { - "_links": { - "self": { - "href": "/dummy_table_inheritances/2" - } - }, - "id": 2, - "name": "Foobarbaz inheritance" - } - ] - }, - "id": 1 - } - """ \ No newline at end of file diff --git a/features/hydra/absolute.feature b/features/hydra/absolute.feature deleted file mode 100644 index ba26000260a..00000000000 --- a/features/hydra/absolute.feature +++ /dev/null @@ -1,23 +0,0 @@ -Feature: Collections with absolute IRIs support - In order to retrieve large collections of resources - As a client software developer - I need to retrieve paged collections respecting the Hydra specification and with absolute iris - - @createSchema - Scenario: Retrieve third page of collection with absolute iris - Given there are 30 absoluteUrlDummy objects with a related absoluteUrlRelationDummy - When I send a "GET" request to "/absolute_url_dummies?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:view" should be equal to: - """ - { - "@id": "http://example.com/absolute_url_dummies?page=3", - "@type": "hydra:PartialCollectionView", - "hydra:first": "http://example.com/absolute_url_dummies?page=1", - "hydra:last": "http://example.com/absolute_url_dummies?page=10", - "hydra:previous": "http://example.com/absolute_url_dummies?page=2", - "hydra:next": "http://example.com/absolute_url_dummies?page=4" - } - """ diff --git a/features/hydra/collection.feature b/features/hydra/collection.feature deleted file mode 100644 index 8e9b0c33aba..00000000000 --- a/features/hydra/collection.feature +++ /dev/null @@ -1,596 +0,0 @@ -Feature: Collections support - In order to retrieve large collections of resources - As a client software developer - I need to retrieve paged collections respecting the Hydra specification - - @createSchema - Scenario: Retrieve an empty collection - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 0}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Retrieve the first page of a collection - Given there are 30 dummy objects - And I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - }, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?page=10$"}, - "hydra:next": {"pattern": "^/dummies\\?page=2$"} - } - } - } - } - """ - - Scenario: Retrieve a page of a collection - When I send a "GET" request to "/dummies?page=7" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/21$"} - ] - } - } - }, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?page=7$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?page=10$"}, - "hydra:next": {"pattern": "^/dummies\\?page=8$"}, - "hydra:previous": {"pattern": "^/dummies\\?page=6$"} - } - } - } - } - """ - - Scenario: Retrieve the last page of a collection - When I send a "GET" request to "/dummies?page=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"}, - {"pattern": "^/dummies/30$"} - ] - } - } - }, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?page=10$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?page=10$"}, - "hydra:previous": {"pattern": "^/dummies\\?page=9$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - @!mongodb - Scenario: Enable the partial pagination client side - When I send a "GET" request to "/dummies?page=7&partial=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/21$"} - ] - } - } - }, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?partial=1&page=7$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:next": {"pattern": "^/dummies\\?partial=1&page=8$"}, - "hydra:previous": {"pattern": "^/dummies\\?partial=1&page=6$"} - }, - "required": ["@id", "@type", "hydra:next", "hydra:previous"], - "additionalProperties": false, - "maxProperties": 4 - } - }, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:view", "hydra:search"], - "maxProperties": 6 - } - """ - - Scenario: Disable the pagination client side - When I send a "GET" request to "/dummies?pagination=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 30}, - "hydra:member": { - "type": "array", - "minItems": 30 - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Change the number of element by page client side - When I send a "GET" request to "/dummies?page=2&itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "minItems": 10, - "maxItems": 10 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?itemsPerPage=10&page=2$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?itemsPerPage=10&page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?itemsPerPage=10&page=3$"}, - "hydra:previous": {"pattern": "^/dummies\\?itemsPerPage=10&page=1$"}, - "hydra:next": {"pattern": "^/dummies\\?itemsPerPage=10&page=3$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - @!mongodb - @php8 - Scenario: Change the number of element by page client side with v3, attributes and PHP8. Defaults (max 40) should not override resource attribute (max 30) - Given there are 80 pagination entities - When I send a "GET" request to "/pagination_entities?page=2&itemsPerPage=40" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/PaginationEntity"}, - "@id": {"pattern": "^/pagination_entities"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 80}, - "hydra:member": { - "type": "array", - "minItems": 30, - "maxItems": 30 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=2$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=1$"}, - "hydra:last": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=3$"}, - "hydra:previous": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=1$"}, - "hydra:next": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=3$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Test presence of next - When I send a "GET" request to "/dummies?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "@id":"/dummies?page=3", - "@type":"hydra:PartialCollectionView", - "hydra:first":"/dummies?page=1", - "hydra:last":"/dummies?page=10", - "hydra:previous":"/dummies?page=2", - "hydra:next":"/dummies?page=4" - } - """ - - Scenario: Filter with exact match - When I send a "GET" request to "/dummies?id=8" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/8$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=8$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Filter with a raw URL - When I send a "GET" request to "/dummies?id=%2fdummies%2f8" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/8$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=%2Fdummies%2F8$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Filter with non-exact match - When I send a "GET" request to "/dummies?name=Dummy%20%238" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/8$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name=Dummy%20%238$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - @createSchema - Scenario: Allow passing 0 to `itemsPerPage` - When I send a "GET" request to "/dummies?itemsPerPage=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "minItems": 0, - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?itemsPerPage=0$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?itemsPerPage=0&page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?itemsPerPage=0&page=1$"}, - "hydra:previous": {"pattern": "^/dummies\\?itemsPerPage=0&page=1$"}, - "hydra:next": {"pattern": "^/dummies\\?itemsPerPage=0&page=1$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - When I send a "GET" request to "/dummies?itemsPerPage=0&page=2" - Then the response status code should be 400 - And the JSON node "detail" should be equal to "Page should not be greater than 1 if limit is equal to 0" - - Scenario: Cursor-based pagination with an empty collection - When I send a "GET" request to "/so_manies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SoMany$"}, - "@id": {"pattern": "^/so_manies$"}, - "@type": {"pattern": "^hydra:Collection"}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/so_manies$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "additionalProperties": false - }, - "hydra:member": { - "type": "array" - } - } - } - """ - - @createSchema - Scenario: Cursor-based pagination with ranged items - Given there are 10 of these so many objects - When I send a "GET" request to "/so_manies?order[id]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SoMany$"}, - "@id": {"pattern": "^/so_manies$"}, - "@type": {"pattern": "^hydra:Collection"}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/so_manies\\?order%5Bid%5D=desc$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:previous": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=10$"}, - "hydra:next": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=8$"} - }, - "additionalProperties": false - }, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/so_manies/8$"}, - {"pattern": "^/so_manies/9$"}, - {"pattern": "^/so_manies/10$"} - ] - } - } - }, - "minItems": 3 - } - } - } - """ - - @createSchema - Scenario: Cursor-based pagination with range filtered items - Given there are 10 of these so many objects - When I send a "GET" request to "/so_manies?order[id]=desc&id[gt]=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SoMany$"}, - "@id": {"pattern": "^/so_manies$"}, - "@type": {"pattern": "^hydra:Collection"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/so_manies\\?id%5Bgt%5D=10&order%5Bid%5D=desc$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:previous": {"pattern": "^/so_manies\\?id%5Bgt%5D=13&order%5Bid%5D=desc$"}, - "hydra:next": {"pattern": "^/so_manies\\?id%5Blt%5D=10&order%5Bid%5D=desc$"} - }, - "additionalProperties": false - } - } - } - """ - - Scenario: Hydra collection without prefix - When I send a "GET" request to "/no_hydra_prefixes" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "totalItems" should exist - And the JSON node "member" should exist diff --git a/features/hydra/docs.feature b/features/hydra/docs.feature deleted file mode 100644 index 76e2d83d8e2..00000000000 --- a/features/hydra/docs.feature +++ /dev/null @@ -1,84 +0,0 @@ -Feature: Documentation support - In order to build an auto-discoverable API - As a client software developer - I need to know Hydra specifications of objects I send and receive - - Scenario: Checks that the Link pointing to the Hydra documentation is set - Given I send a "GET" request to "/" - Then the header "Link" should be equal to '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' - - Scenario: Retrieve the API vocabulary - Given I send a "GET" request to "/docs.jsonld" - 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" - # Context - And the Hydra context matches the online resource "http://www.w3.org/ns/hydra/context.jsonld" - And the JSON node "@context[1].@vocab" should be equal to "http://example.com/docs.jsonld#" - And the JSON node "@context[1].domain.@id" should be equal to "rdfs:domain" - And the JSON node "@context[1].domain.@type" should be equal to "@id" - And the JSON node "@context[1].range.@id" should be equal to "rdfs:range" - And the JSON node "@context[1].range.@type" should be equal to "@id" - And the JSON node "@context[1].subClassOf.@id" should be equal to "rdfs:subClassOf" - And the JSON node "@context[1].subClassOf.@type" should be equal to "@id" - # Root properties - And the JSON node "@id" should be equal to "/docs.jsonld" - And the JSON node "hydra:title" should be equal to "My Dummy API" - And the JSON node "hydra:description" should contain "This is a test API." - And the JSON node "hydra:description" should contain "Made with love" - And the JSON node "hydra:entrypoint" should be equal to "/" - # Supported classes - And the Hydra class "Entrypoint" exists - And the Hydra class "ConstraintViolation" exists - And the Hydra class "ConstraintViolationList" exists - And the Hydra class "CircularReference" exists - And the Hydra class "CustomIdentifierDummy" exists - And the Hydra class "CustomNormalizedDummy" exists - And the Hydra class "CustomWritableIdentifierDummy" exists - And the Hydra class "Dummy" exists - And the Hydra class "RelatedDummy" exists - And the Hydra class "RelationEmbedder" exists - And the Hydra class "ThirdLevel" exists - And the Hydra class "ParentDummy" doesn't exist - And the Hydra class "UnknownDummy" doesn't exist - # Doc - And the value of the node "@id" of the Hydra class "Dummy" is "#Dummy" - And the value of the node "@type" of the Hydra class "Dummy" is "hydra:Class" - And the value of the node "hydra:title" of the Hydra class "Dummy" is "Dummy" - And the value of the node "hydra:description" of the Hydra class "Dummy" is "Dummy." - # Properties - And "name" property is readable for Hydra class "Dummy" - And "name" property is writable for Hydra class "Dummy" - And "name" property is required for Hydra class "Dummy" - And "plainPassword" property is not readable for Hydra class "User" - And "plainPassword" property is writable for Hydra class "User" - And "plainPassword" property is not required for Hydra class "User" - And the value of the node "@type" of the property "name" of the Hydra class "Dummy" is "hydra:SupportedProperty" - And the value of the node "hydra:property.@id" of the property "name" of the Hydra class "Dummy" is "https://schema.org/name" - And the value of the node "hydra:property.@type" of the property "name" of the Hydra class "Dummy" is "rdf:Property" - And the value of the node "hydra:property.label" of the property "name" of the Hydra class "Dummy" is "name" - And the value of the node "hydra:property.domain" of the property "name" of the Hydra class "Dummy" is "#Dummy" - And the value of the node "hydra:property.range" of the property "name" of the Hydra class "Dummy" is "xsd:string" - And the value of the node "subClassOf" of the Hydra class "RelatedDummy" is "https://schema.org/Product" - And the value of the node "hydra:property.range" of the property "relatedDummy" of the Hydra class "Dummy" is "#RelatedDummy" - And the value of the node "hydra:property.owl:maxCardinality" of the property "relatedDummy" of the Hydra class "Dummy" is "1" - And the value of the node "hydra:property.range" of the property "relatedDummies" of the Hydra class "Dummy" is "#RelatedDummy" - And the value of the node "hydra:title" of the property "name" of the Hydra class "Dummy" is "name" - And the value of the node "hydra:description" of the property "name" of the Hydra class "Dummy" is "The dummy name" - # Operations - And the value of the node "@type" of the operation "GET" of the Hydra class "Dummy" contains "hydra:Operation" - And the value of the node "@type" of the operation "GET" of the Hydra class "Dummy" contains "schema:FindAction" - And the value of the node "hydra:method" of the operation "GET" of the Hydra class "Dummy" is "GET" - And the value of the node "hydra:title" of the operation "GET" of the Hydra class "Dummy" is "getDummy" - And the value of the node "hydra:description" of the operation "GET" of the Hydra class "Dummy" is "Retrieves a Dummy resource." - And the value of the node "returns" of the operation "GET" of the Hydra class "Dummy" is "Dummy" - And the value of the node "hydra:title" of the operation "PUT" of the Hydra class "Dummy" is "putDummy" - And the value of the node "hydra:description" of the operation "PUT" of the Hydra class "Dummy" is "Replaces the Dummy resource." - And the value of the node "hydra:description" of the operation "DELETE" of the Hydra class "Dummy" is "Deletes the Dummy resource." - And the value of the node "hydra:title" of the operation "DELETE" of the Hydra class "Dummy" is "deleteDummy" - And the value of the node "returns" of the operation "DELETE" of the Hydra class "Dummy" is "owl:Nothing" - # Deprecations - And the boolean value of the node "owl:deprecated" of the Hydra class "DeprecatedResource" is true - And the boolean value of the node "hydra:property.owl:deprecated" of the property "deprecatedField" of the Hydra class "DeprecatedResource" is true - And the boolean value of the node "owl:deprecated" of the property "getDeprecatedResourceCollection" of the Hydra class "Entrypoint" is true - And the boolean value of the node "owl:deprecated" of the operation "GET" of the Hydra class "DeprecatedResource" is true diff --git a/features/hydra/entrypoint.feature b/features/hydra/entrypoint.feature deleted file mode 100644 index b2b8f731d27..00000000000 --- a/features/hydra/entrypoint.feature +++ /dev/null @@ -1,30 +0,0 @@ -Feature: Entrypoint support - In order to build an auto-discoverable API - As a client software developer - I need to access to an entrypoint listing top-level resources - - Scenario: Retrieve the Entrypoint - When I add "Accept" header equal to "application/ld+json" - When I send a "GET" request to "/" - 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 sorted - And the JSON node "@context" should be equal to "/contexts/Entrypoint" - And the JSON node "@id" should be equal to "/" - And the JSON node "@type" should be equal to "Entrypoint" - And the JSON node "abstractDummy" should be equal to "/abstract_dummies" - And the JSON node "circularReference" should be equal to "/circular_references" - And the JSON node "compositeItem" should be equal to "/composite_items" - And the JSON node "compositeLabel" should be equal to "/composite_labels" - And the JSON node "compositeRelation" should be equal to "/composite_relations" - And the JSON node "concreteDummy" should be equal to "/concrete_dummies" - And the JSON node "customIdentifierDummy" should be equal to "/custom_identifier_dummies" - And the JSON node "customNormalizedDummy" should be equal to "/custom_normalized_dummies" - And the JSON node "customWritableIdentifierDummy" should be equal to "/custom_writable_identifier_dummies" - And the JSON node "dummy" should be equal to "/dummies" - And the JSON node "relatedDummy" should be equal to "/related_dummies" - And the JSON node "relationEmbedder" should be equal to "/relation_embedders" - And the JSON node "thirdLevel" should be equal to "/third_levels" - And the JSON node "user" should be equal to "/users" - And the JSON node "fileconfigdummy" should be equal to "/fileconfigdummies" diff --git a/features/hydra/error.feature b/features/hydra/error.feature deleted file mode 100644 index 07fe8210f02..00000000000 --- a/features/hydra/error.feature +++ /dev/null @@ -1,138 +0,0 @@ -@!mongodb -Feature: Error handling - In order to be able to handle error client side - As a client software developer - I need to retrieve an Hydra serialization of errors - That is compatible with the JSON Problem specification - - Scenario: Get an rfc 7807 error - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/exception_problems" with body: - """ - {} - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "type" should exist - And the JSON node "title" should not exists - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "description" should not exist - And the JSON node "hydra:description" should exist - And the JSON node "trace" should exist - And the JSON node "status" should exist - And the JSON node "@context" should exist - - Scenario: Get validation constraint violations - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_problems" with body: - """ - {} - """ - Then the response status code should be 422 - 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 should be equal to: - """ - { - "@context": "/contexts/ConstraintViolation", - "@id": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", - "@type": "ConstraintViolation", - "status": 422, - "violations": [ - { - "propertyPath": "name", - "message": "This value should not be blank.", - "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" - } - ], - "detail": "name: This value should not be blank.", - "hydra:title": "An error occurred", - "hydra:description": "name: This value should not be blank.", - "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3" - } - """ - - Scenario: Get an rfc 7807 bad request error - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/exception_problems" with body: - """ - {} - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "@context" should exist - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - - Scenario: Get an rfc 7807 not found error - When I add "Accept" header equal to "application/ld+json" - And I send a "POST" request to "/does_not_exist" with body: - """ - {} - """ - 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 header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "@context" should exist - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "description" should not exist - - Scenario: Get an rfc 7807 bad method error - When I add "Content-Type" header equal to "application/ld+json" - And I add "Accept" header equal to "application/ld+json" - And I send a "PATCH" request to "/dummy_problems" with body: - """ - {} - """ - Then the response status code should be 405 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "@context" should exist - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "description" should not exist - - Scenario: Get an rfc 7807 validation error - When I add "Content-Type" header equal to "application/ld+json" - And I add "Accept" header equal to "application/ld+json" - And I send a "POST" request to "/validation_exception_problems" with body: - """ - {} - """ - Then the response status code should be 422 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "@context" should exist - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "violations" should exist - - Scenario: Get an rfc 7807 error - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/exception_problems_without_prefix" with body: - """ - {} - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "description" should not exist - And the JSON node "trace" should exist - And the JSON node "status" should exist diff --git a/features/hydra/item_uri_template.feature b/features/hydra/item_uri_template.feature deleted file mode 100644 index 0c732832351..00000000000 --- a/features/hydra/item_uri_template.feature +++ /dev/null @@ -1,237 +0,0 @@ -@!mongodb -@v3 -Feature: Exposing a collection of objects should use the specified operation to generate the IRI - Background: - Given I add "Accept" header equal to "application/ld+json" - - Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation - When I send a "GET" request to "/cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Car$"}, - "@id": {"pattern": "^/cars$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/cars/.+$"}, - "@type": {"pattern": "^Car$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation - When I send a "GET" request to "/brands/renault/cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Car$"}, - "@id": {"pattern": "^/brands/renault/cars$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/brands/renault/cars/.+$"}, - "@type": {"pattern": "^Car$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - Scenario: Create an object without an itemUriTemplate should generate the IRI from the first Get operation - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/cars" with body: - """ - { - "owner": "Vincent" - } - """ - 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 valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@context": {"pattern": "^/contexts/Car$"}, - "@id": {"pattern": "^/cars/.+$"}, - "@type": {"pattern": "^Car$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - """ - - Scenario: Create an object with an itemUriTemplate should generate the IRI from the correct operation - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/brands/renault/cars" with body: - """ - { - "owner": "Vincent" - } - """ - 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 valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@context": {"pattern": "^/contexts/Car$"}, - "@id": {"pattern": "^/brands/renault/cars/.+$"}, - "@type": {"pattern": "^Car$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - """ - - Scenario: Get a collection referencing another resource for its IRI - When I send a "GET" request to "/item_referenced_in_collection" - 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/CollectionReferencingItem", - "@id":"/item_referenced_in_collection", - "@type":"hydra:Collection", - "hydra:member":[ - { - "@id":"/item_referenced_in_collection/a", - "@type":"ItemReferencedInCollection", - "id":"a", - "name":"hello" - }, - { - "@id":"/item_referenced_in_collection/b", - "@type":"ItemReferencedInCollection", - "id":"b", - "name":"you" - } - ], - "hydra:totalItems":2 - } - """ - - Scenario: Get a collection referencing an itemUriTemplate - When I send a "GET" request to "/issue5662/books/a/reviews" - 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/Review", - "@id":"/issue5662/books/a/reviews", - "@type":"hydra:Collection", - "hydra:member":[ - { - "@id":"/issue5662/books/a/reviews/1", - "@type":"Review", - "book":"/issue5662/books/a", - "id":1, - "body":"Best book ever!" - }, - { - "@id":"/issue5662/books/b/reviews/2", - "@type":"Review", - "book":"/issue5662/books/b", - "id":2, - "body":"Worst book ever!" - } - ], - "hydra:totalItems":2 - } - """ - - Scenario: Get a collection referencing an invalid itemUriTemplate - When I send a "GET" request to "/issue5662/admin/reviews" - 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/Review", - "@id": "/issue5662/admin/reviews", - "@type": "hydra:Collection", - "hydra:totalItems": 2, - "hydra:member": [ - { - "@id": "/issue5662/admin/reviews/1", - "@type": "Review", - "book": "/issue5662/books/a", - "id": 1, - "body": "Best book ever!" - }, - { - "@id": "/issue5662/admin/reviews/2", - "@type": "Review", - "book": "/issue5662/books/b", - "id": 2, - "body": "Worst book ever!" - } - ] - } - """ - - Scenario: Create an object with an itemUriTemplate should generate the IRI according to the specified itemUriTemplate - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue5662/books/a/reviews" with body: - """ - { - "body": "Good book" - } - """ - 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 node "@id" should be equal to "/issue5662/books/a/reviews/0" diff --git a/features/jsonapi/absolute_url.feature b/features/jsonapi/absolute_url.feature deleted file mode 100644 index 45b4444e442..00000000000 --- a/features/jsonapi/absolute_url.feature +++ /dev/null @@ -1,125 +0,0 @@ -Feature: IRI should contain Absolute URL - In order to add detail to IRIs - Include the absolute url - - @createSchema - Scenario: I should be able to GET a collection of Objects with Absolute Urls - Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy - And I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "links": { - "self": "http://example.com/absolute_url_dummies" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "http://example.com/absolute_url_dummies/1", - "type": "AbsoluteUrlDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "absoluteUrlRelationDummy": { - "data": { - "type": "AbsoluteUrlRelationDummy", - "id": "http://example.com/absolute_url_relation_dummies/1" - } - } - } - } - ] - } - """ - - Scenario: I should be able to POST an object using an Absolute Url - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/absolute_url_relation_dummies" with body: - """ - { - "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "data": { - "id": "http://example.com/absolute_url_relation_dummies/2", - "type": "AbsoluteUrlRelationDummy", - "attributes": { - "_id": 2 - }, - "relationships": { - "absoluteUrlDummies": { - "data": [] - } - } - } - } - """ - - Scenario: I should be able to GET an Item with Absolute Urls - Given I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/absolute_url_dummies/1" - And the JSON should be equal to: - """ - { - "data": { - "id": "http://example.com/absolute_url_dummies/1", - "type": "AbsoluteUrlDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "absoluteUrlRelationDummy": { - "data": { - "type": "AbsoluteUrlRelationDummy", - "id": "http://example.com/absolute_url_relation_dummies/1" - } - } - } - } - } - """ - - Scenario: I should be able to GET resources with Absolute Urls - Given I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "links": { - "self": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "http://example.com/absolute_url_dummies/1", - "type": "AbsoluteUrlDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "absoluteUrlRelationDummy": { - "data": { - "type": "AbsoluteUrlRelationDummy", - "id": "http://example.com/absolute_url_relation_dummies/1" - } - } - } - } - ] - } - """ diff --git a/features/jsonapi/collection_attributes.feature b/features/jsonapi/collection_attributes.feature deleted file mode 100644 index 6603d36087d..00000000000 --- a/features/jsonapi/collection_attributes.feature +++ /dev/null @@ -1,20 +0,0 @@ -Feature: JSON API collections support - In order to use the JSON API hypermedia format - As a client software developer - I need to be able to retrieve valid JSON API responses for collection attributes on entities. - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Correctly serialize a collection - Given there is a CircularReference - When I send a "GET" request to "/circular_references/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should be equal to "/circular_references/1" - And the JSON node "data.relationships.parent.data.id" should be equal to "/circular_references/1" - And the JSON node "data.relationships.children.data[0].id" should match "#/circular_references/(1|2)#" - And the JSON node "data.relationships.children.data[1].id" should match "#/circular_references/(1|2)#" diff --git a/features/jsonapi/collection_uri_template.feature b/features/jsonapi/collection_uri_template.feature deleted file mode 100644 index 6fa13c00732..00000000000 --- a/features/jsonapi/collection_uri_template.feature +++ /dev/null @@ -1,60 +0,0 @@ -@php8 -@v3 -Feature: Exposing a property being a collection of resources - can return an IRI instead of an array - when the uriTemplate is set on the ApiProperty attribute - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Retrieve Resource with uriTemplate collection Property - Given there are propertyCollectionIriOnly with relations - And I send a "GET" request to "/property_collection_iri_onlies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "links": { - "propertyCollectionIriOnlyRelation": "/property-collection-relations", - "iterableIri": "/parent/1/another-collection-operations", - "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" - }, - "data": { - "id": "/property_collection_iri_onlies/1", - "type": "PropertyCollectionIriOnly", - "relationships": { - "propertyCollectionIriOnlyRelation": { - "data": [ - { - "type": "PropertyCollectionIriOnlyRelation", - "id": "/property_collection_iri_only_relations/1" - }, - { - "type": "PropertyCollectionIriOnlyRelation", - "id": "/property_collection_iri_only_relations/2" - } - ] - }, - "iterableIri": { - "data": [ - { - "type": "PropertyCollectionIriOnlyRelation", - "id": "/property_collection_iri_only_relations/9999" - } - ] - }, - "toOneRelation": { - "data": { - "type": "PropertyUriTemplateOneToOneRelation", - "id": "/parent/1/property-uri-template/one-to-ones/1" - } - } - } - } - } - """ diff --git a/features/jsonapi/errors.feature b/features/jsonapi/errors.feature deleted file mode 100644 index 24e99594478..00000000000 --- a/features/jsonapi/errors.feature +++ /dev/null @@ -1,63 +0,0 @@ -@!mongodb -Feature: JSON API error handling - In order to be able to handle error client side - As a client software developer - I need to retrieve an JSON API serialization of errors - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Get a validation error on an attribute - When I send a "POST" request to "/dummy_problems" with body: - """ - { - "data": { - "type": "dummy", - "attributes": {} - } - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "errors": [ - { - "detail": "This value should not be blank.", - "source": { - "pointer": "data/attributes/name" - } - } - ] - } - """ - - Scenario: Get an rfc 7807 error - When I send a "POST" request to "/exception_problems" with body: - """ - {} - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON node "errors[0].title" should be equal to "An error occurred" - And the JSON node "errors[0].status" should be equal to 400 - And the JSON node "errors[0].detail" should exist - And the JSON node "errors[0].type" should exist - - Scenario: Get an rfc 7807 error - When I send a "POST" request to "/does_not_exist" with body: - """ - {} - """ - 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/vnd.api+json; charset=utf-8" - And the JSON node "errors[0].title" should be equal to "An error occurred" - And the JSON node "errors[0].status" should be equal to 404 - And the JSON node "errors[0].detail" should exist - And the JSON node "errors[0].type" should exist diff --git a/features/jsonapi/filtering.feature b/features/jsonapi/filtering.feature deleted file mode 100644 index ce8f209caa5..00000000000 --- a/features/jsonapi/filtering.feature +++ /dev/null @@ -1,51 +0,0 @@ -Feature: JSON API filter handling - In order to be able to handle filtering - As a client software developer - I need to be able to specify filtering parameters according to JSON API recommendation - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Apply filters based on the 'filter' query parameter with 'my' as value - Given there are 30 dummy objects with dummyDate - When I send a "GET" request to "/dummies?filter[name]=my" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 3 elements - - Scenario: Apply filters based on the 'filter' query parameter with 'foo' as value - When I send a "GET" request to "/dummies?filter[name]=foo" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 0 elements - - Scenario: Apply filters and pagination at the same time - When I send a "GET" request to "/dummies?filter[name]=foo&page[page]=2" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - Then the JSON node "meta.currentPage" should be a number - Then the JSON node "meta.currentPage" should be equal to "2" - - Scenario: Apply property filter based on the 'fields' - Given there are 2 dummy property objects - When I send a "GET" request to "/dummy_properties?fields[DummyProperty]=id,foo,bar" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 2 elements - And the JSON node "data[0].attributes._id" should be equal to "1" - And the JSON node "data[0].attributes.foo" should be equal to "Foo #1" - And the JSON node "data[0].attributes.bar" should be equal to "Bar #1" - And the JSON node "data[0].attributes.group" should not exist - - Scenario: Apply filters based on the 'filter' query parameter with second level arguments - When I send a "GET" request to "/dummies?filter[dummyDate][after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 2 elements diff --git a/features/jsonapi/input_output.feature b/features/jsonapi/input_output.feature deleted file mode 100644 index 1fb6771081d..00000000000 --- a/features/jsonapi/input_output.feature +++ /dev/null @@ -1,59 +0,0 @@ -Feature: JSON API DTO input and output - In order to use a hypermedia 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/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Get an item with a custom output - Given there is a DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output/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" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": { - "type": "CustomOutputDto", - "attributes": { - "foo": "test", - "bar": 1 - } - } - } - """ - - @createSchema - Scenario: Get a collection with a custom output - Given there are 2 DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output" - 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" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": [ - { - "type": "CustomOutputDto", - "attributes": { - "foo": "test", - "bar": 1 - } - }, - { - "type": "CustomOutputDto", - "attributes": { - "foo": "test", - "bar": 2 - } - } - ] - } - """ diff --git a/features/jsonapi/item_uri_template.feature b/features/jsonapi/item_uri_template.feature deleted file mode 100644 index 7dbfb223580..00000000000 --- a/features/jsonapi/item_uri_template.feature +++ /dev/null @@ -1,200 +0,0 @@ -@php8 -@v3 -Feature: Exposing a collection of objects should use the specified operation to generate the IRI - - Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/cars" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["links", "meta", "data"], - "properties": { - "links": { - "type": "object", - "additionalProperties": false, - "required": ["self"], - "properties": { - "self": {"pattern": "^/cars$"} - } - }, - "meta": { - "type": "object", - "additionalProperties": false, - "required": ["totalItems"], - "properties": { - "totalItems": {"type": "number", "minimum": 2, "maximum": 2} - } - }, - "data": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "attributes"], - "properties": { - "id": {"pattern": "^/cars/.+$"}, - "type": {"pattern": "^Car$"}, - "attributes": { - "type": "object", - "additionalProperties": false, - "required": ["_id", "owner"], - "properties": { - "_id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - } - """ - - Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/brands/renault/cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["links", "meta", "data"], - "properties": { - "links": { - "type": "object", - "additionalProperties": false, - "required": ["self"], - "properties": { - "self": {"pattern": "^/brands/renault/cars$"} - } - }, - "meta": { - "type": "object", - "additionalProperties": false, - "required": ["totalItems"], - "properties": { - "totalItems": {"type": "number", "minimum": 2, "maximum": 2} - } - }, - "data": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "attributes"], - "properties": { - "id": {"pattern": "^/brands/renault/cars/.+$"}, - "type": {"pattern": "^Car$"}, - "attributes": { - "type": "object", - "additionalProperties": false, - "required": ["_id", "owner"], - "properties": { - "_id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - } - """ - - Scenario: Create an object without an itemUriTemplate should generate the IRI from the first Get operation - When I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/cars" with body: - """ - { - "owner": "Vincent" - } - """ - 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/vnd.api+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["data"], - "properties": { - "data": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "attributes"], - "properties": { - "id": {"pattern": "^/cars/.+$"}, - "type": {"pattern": "^Car$"}, - "attributes": { - "type": "object", - "additionalProperties": false, - "required": ["_id", "owner"], - "properties": { - "_id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - """ - - Scenario: Create an object with an itemUriTemplate should generate the IRI from the correct operation - When I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/brands/renault/cars" with body: - """ - { - "owner": "Vincent" - } - """ - 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/vnd.api+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["data"], - "properties": { - "data": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "attributes"], - "properties": { - "id": {"pattern": "^/brands/renault/cars/.+$"}, - "type": {"pattern": "^Car$"}, - "attributes": { - "type": "object", - "additionalProperties": false, - "required": ["_id", "owner"], - "properties": { - "_id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - """ diff --git a/features/jsonapi/jsonapi.feature b/features/jsonapi/jsonapi.feature deleted file mode 100644 index 39a5d3920ea..00000000000 --- a/features/jsonapi/jsonapi.feature +++ /dev/null @@ -1,257 +0,0 @@ -Feature: JSON API basic support - In order to use the JSON API hypermedia format - As a client software developer - I need to be able to retrieve valid JSON API responses. - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Retrieve the API entrypoint - When I send a "GET" request to "/" - 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" - And the JSON node "links.self" should be equal to "http://example.com/" - And the JSON node "links.dummy" should be equal to "http://example.com/dummies" - - Scenario: Test empty list against JSON API schema - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should be an empty array - - Scenario: Create a ThirdLevel - When I send a "POST" request to "/third_levels" with body: - """ - { - "data": { - "type": "third-level", - "attributes": { - "level": 3 - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should not be an empty string - - Scenario: Retrieve the collection - When I send a "GET" request to "/third_levels" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - - Scenario: Retrieve the third level - When I send a "GET" request to "/third_levels/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - - Scenario: Create a related dummy - When I send a "POST" request to "/related_dummies" with body: - """ - { - "data": { - "type": "related-dummy", - "attributes": { - "name": "John Doe", - "age": 23 - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "third-level", - "id": "/third_levels/1" - } - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should not be an empty string - And the JSON node "data.attributes.name" should be equal to "John Doe" - And the JSON node "data.attributes.age" should be equal to the number 23 - - Scenario: Create a dummy with relations - Given there is a RelatedDummy - When I send a "POST" request to "/dummies" with body: - """ - { - "data": { - "type": "dummy", - "attributes": { - "name": "Dummy with relations", - "dummyDate": "2015-03-01T10:00:00+00:00" - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "related-dummy", - "id": "/related_dummies/2" - } - }, - "relatedDummies": { - "data": [ - { - "type": "related-dummy", - "id": "/related_dummies/1" - }, - { - "type": "related-dummy", - "id": "/related_dummies/2" - } - ] - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.relationships.relatedDummies.data" should have 2 elements - And the JSON node "data.relationships.relatedDummy.data.id" should be equal to "/related_dummies/2" - - Scenario: Update a resource with a many-to-many relationship via PATCH - When I send a "PATCH" request to "/dummies/1" with body: - """ - { - "data": { - "type": "dummy", - "relationships": { - "relatedDummy": { - "data": { - "type": "related-dummy", - "id": "/related_dummies/1" - } - }, - "relatedDummies": { - "data": [ - { - "type": "related-dummy", - "id": "/related_dummies/2" - } - ] - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.relationships.relatedDummies.data" should have 1 elements - And the JSON node "data.relationships.relatedDummy.data.id" should be equal to "/related_dummies/1" - - Scenario: Create a related dummy with an empty relationship - When I send a "POST" request to "/related_dummies" with body: - """ - { - "data": { - "type": "related-dummy", - "attributes": { - "name": "John Doe" - }, - "relationships": { - "thirdLevel": { - "data": null - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - - Scenario: Retrieve a collection with relationships - When I send a "GET" request to "/related_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data[0].relationships.thirdLevel.data.id" should be equal to "/third_levels/1" - - Scenario: Retrieve the related dummy - When I send a "GET" request to "/related_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "John Doe", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": [], - "age": 23 - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - } - } - """ - - Scenario: Update a resource via PATCH - When I send a "PATCH" request to "/related_dummies/1" with body: - """ - { - "data": { - "type": "related-dummy", - "attributes": { - "name": "Jane Doe" - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should not be an empty string - And the JSON node "data.attributes.name" should be equal to "Jane Doe" - And the JSON node "data.attributes.age" should be equal to the number 23 - - Scenario: Embed a relation in a parent object - When I send a "POST" request to "/relation_embedders" with body: - """ - { - "data": { - "relationships": { - "related": { - "data": { - "type": "related-dummy", - "id": "/related_dummies/1" - } - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should not be an empty string - And the JSON node "data.attributes.krondstadt" should be equal to "Krondstadt" - And the JSON node "data.relationships.related.data.id" should be equal to "/related_dummies/1" diff --git a/features/jsonapi/network_path.feature b/features/jsonapi/network_path.feature deleted file mode 100644 index 810ba0f9612..00000000000 --- a/features/jsonapi/network_path.feature +++ /dev/null @@ -1,125 +0,0 @@ -Feature: IRI should contain network path - In order to add detail to IRIs - Include the network path - - @createSchema - Scenario: I should be able to GET a collection of objects with network paths - Given there are 1 networkPathDummy objects with a related networkPathRelationDummy - And I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/network_path_dummies" - And the JSON should be equal to: - """ - { - "links": { - "self": "//example.com/network_path_dummies" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "//example.com/network_path_dummies/1", - "type": "NetworkPathDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "networkPathRelationDummy": { - "data": { - "type": "NetworkPathRelationDummy", - "id": "//example.com/network_path_relation_dummies/1" - } - } - } - } - ] - } - """ - - Scenario: I should be able to POST an object using a network path - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/network_path_relation_dummies" with body: - """ - { - "network_path_dummies": "//example.com/network_path_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "data": { - "id": "//example.com/network_path_relation_dummies/2", - "type": "NetworkPathRelationDummy", - "attributes": { - "_id": 2 - }, - "relationships": { - "networkPathDummies": { - "data": [] - } - } - } - } - """ - - Scenario: I should be able to GET an Item with network paths - Given I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/network_path_dummies/1" - And the JSON should be equal to: - """ - { - "data": { - "id": "//example.com/network_path_dummies/1", - "type": "NetworkPathDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "networkPathRelationDummy": { - "data": { - "type": "NetworkPathRelationDummy", - "id": "//example.com/network_path_relation_dummies/1" - } - } - } - } - } - """ - - Scenario: I should be able to GET resources with network paths - Given I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" - And the JSON should be equal to: - """ - { - "links": { - "self": "//example.com/network_path_relation_dummies/1/network_path_dummies" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "//example.com/network_path_dummies/1", - "type": "NetworkPathDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "networkPathRelationDummy": { - "data": { - "type": "NetworkPathRelationDummy", - "id": "//example.com/network_path_relation_dummies/1" - } - } - } - } - ] - } - """ diff --git a/features/jsonapi/non_resource.feature b/features/jsonapi/non_resource.feature deleted file mode 100644 index 493e83efcc0..00000000000 --- a/features/jsonapi/non_resource.feature +++ /dev/null @@ -1,126 +0,0 @@ -Feature: JSON API non-resource handling - 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/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/contain_non_resources/1?include=nested" - 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" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": { - "id": "/contain_non_resources/1", - "type": "ContainNonResource", - "attributes": { - "_id": 1, - "notAResource": { - "foo": "f1", - "bar": "b1" - } - }, - "relationships": { - "nested": { - "data": { - "id": "/contain_non_resources/1-nested", - "type": "ContainNonResource" - } - } - } - }, - "included": [ - { - "id": "/contain_non_resources/1-nested", - "type": "ContainNonResource", - "attributes": { - "_id": "1-nested", - "notAResource": { - "foo": "f2", - "bar": "b2" - } - } - } - ] - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that has a non-resource relation. - When I send a "POST" request to "/non_relation_resources" with body: - """ - { - "data": { - "type": "NonRelationResource", - "attributes": { - "relation": { - "foo": "test" - } - } - } - } - """ - 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/vnd.api+json; charset=utf-8" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": { - "id": "/non_relation_resources/1", - "type": "NonRelationResource", - "attributes": { - "_id": 1, - "relation": { - "foo": "test" - } - } - } - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that contains a stdClass object. - When I send a "POST" request to "/plain_object_dummies" with body: - """ - { - "data": { - "type": "PlainObjectDummy", - "attributes": { - "content":"{\"fields\":{\"title\":{\"value\":\"\"},\"images\":[{\"id\":0,\"categoryId\":0,\"uri\":\"/api/pictures\",\"resource\":\"{}\",\"description\":\"\",\"alt\":\"\",\"type\":\"picture\",\"text\":\"\",\"src\":\"\"}],\"alternativeAudio\":{},\"caption\":\"\"},\"showCaption\":false,\"alternativeContent\":false,\"alternativeAudioContent\":false,\"blockLayout\":\"default\"}" - } - } - } - """ - 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/vnd.api+json; charset=utf-8" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": { - "id": "/plain_object_dummies/1", - "type": "PlainObjectDummy", - "attributes": { - "_id": 1, - "data": { - "fields": [], - "showCaption": false, - "alternativeContent": false, - "alternativeAudioContent": false, - "blockLayout": "default" - } - } - } - } - """ diff --git a/features/jsonapi/ordering.feature b/features/jsonapi/ordering.feature deleted file mode 100644 index b3d4f687a3c..00000000000 --- a/features/jsonapi/ordering.feature +++ /dev/null @@ -1,144 +0,0 @@ -Feature: JSON API order handling - In order to be able to handle ordering - As a client software developer - I need to be able to specify ordering parameters according to JSON API recommendation - - Background: - Given I add "Content-Type" header equal to "application/vnd.api+json" - And I add "Accept" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Get collection ordered in ascending order on an integer property and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?sort=id" - Then the response status code should be 200 - And the JSON should be valid according to the JSON API schema - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^1$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^2$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^3$" - } - } - } - ] - } - } - } - """ - - Scenario: Get collection ordered in descending order on an integer property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?sort=-id" - Then the response status code should be 200 - And the JSON should be valid according to the JSON API schema - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^30$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^29$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^28$" - } - } - } - ] - } - } - } - """ - - Scenario: Get collection ordered on two properties previously whitelisted - When I send a "GET" request to "/dummies?sort=description,-id" - Then the JSON should be valid according to the JSON API schema - Then the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^30$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^28$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^26$" - } - } - } - ] - } - } - } - """ diff --git a/features/jsonapi/pagination.feature b/features/jsonapi/pagination.feature deleted file mode 100644 index d07afd8d89c..00000000000 --- a/features/jsonapi/pagination.feature +++ /dev/null @@ -1,42 +0,0 @@ -Feature: JSON API pagination handling - In order to be able to handle pagination - As a client software developer - I need to retrieve an JSON API pagination information as metadata and links - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Get the first page of a paginated collection according to basic config - Given there are 10 dummy objects - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 3 elements - And the JSON node "meta.totalItems" should be equal to the number 10 - And the JSON node "meta.itemsPerPage" should be equal to the number 3 - And the JSON node "meta.currentPage" should be equal to the number 1 - - Scenario: Get the fourth page of a paginated collection according to basic config - When I send a "GET" request to "/dummies?page[page]=4" - Then the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 1 elements - And the JSON node "meta.currentPage" should be equal to the number 4 - - Scenario: Get a paginated collection according to custom items per page in request - When I send a "GET" request to "/dummies?page[itemsPerPage]=15" - Then the response status code should be 200 - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 10 elements - And the JSON node "meta.totalItems" should be equal to the number 10 - And the JSON node "meta.itemsPerPage" should be equal to the number 15 - And the JSON node "meta.currentPage" should be equal to the number 1 - - Scenario: Get an error when provided page number is not valid - When I send a "GET" request to "/dummies?page[page]=0" - Then the response status code should be 400 - - Scenario: Get an error when provided page number is too large - When I send a "GET" request to "/dummies?page[page]=9223372036854775807" - Then the response status code should be 400 diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature deleted file mode 100644 index 2a6663d3fd1..00000000000 --- a/features/jsonapi/related-resouces-inclusion.feature +++ /dev/null @@ -1,1637 +0,0 @@ -Feature: JSON API Inclusion of Related Resources - In order to be able to handle inclusion of related resources - As a client software developer - I need to be able to specify include parameters according to JSON API recommendation - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Request inclusion of a related resource (many to one) - Given there are 3 dummy property objects - When I send a "GET" request to "/dummy_properties/1?include=group" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": "NameConverted #1" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - }, - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of a non existing related resource - Given there are 3 dummy property objects - When I send a "GET" request to "/dummy_properties/1?include=foo" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": "NameConverted #1" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - } - } - """ - - @createSchema - Scenario: Request inclusion of a related resource keeping main object properties unfiltered - Given there are 3 dummy property objects - When I send a "GET" request to "/dummy_properties/1?include=group&fields[group]=id,foo&fields[DummyProperty]=bar,baz" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "bar": "Bar #1", - "baz": "Baz #1" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - } - } - }, - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1" - } - } - ] - } - """ - - Scenario: Request inclusion of related resources and specific fields - When I send a "GET" request to "/dummy_properties/1?include=group&fields[group]=id,foo" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - } - } - }, - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of related resources (many to many) - Given there are 1 dummy property objects with 3 groups - When I send a "GET" request to "/dummy_properties/1?include=groups" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [ - { - "type": "DummyGroup", - "id": "/dummy_groups/2" - }, - { - "type": "DummyGroup", - "id": "/dummy_groups/3" - }, - { - "type": "DummyGroup", - "id": "/dummy_groups/4" - } - ] - } - } - }, - "included": [ - { - "id": "/dummy_groups/2", - "type": "DummyGroup", - "attributes": { - "_id": 2, - "foo": "Foo #11", - "bar": "Bar #11", - "baz": "Baz #11" - } - }, - { - "id": "/dummy_groups/3", - "type": "DummyGroup", - "attributes": { - "_id": 3, - "foo": "Foo #12", - "bar": "Bar #12", - "baz": "Baz #12" - } - }, - { - "id": "/dummy_groups/4", - "type": "DummyGroup", - "attributes": { - "_id": 4, - "foo": "Foo #13", - "bar": "Bar #13", - "baz": "Baz #13" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of related resources (many to many and many to one) - Given there are 1 dummy property objects with 3 groups - When I send a "GET" request to "/dummy_properties/1?include=groups,group" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [ - { - "type": "DummyGroup", - "id": "/dummy_groups/2" - }, - { - "type": "DummyGroup", - "id": "/dummy_groups/3" - }, - { - "type": "DummyGroup", - "id": "/dummy_groups/4" - } - ] - } - } - }, - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1" - } - }, - { - "id": "/dummy_groups/2", - "type": "DummyGroup", - "attributes": { - "_id": 2, - "foo": "Foo #11", - "bar": "Bar #11", - "baz": "Baz #11" - } - }, - { - "id": "/dummy_groups/3", - "type": "DummyGroup", - "attributes": { - "_id": 3, - "foo": "Foo #12", - "bar": "Bar #12", - "baz": "Baz #12" - } - }, - { - "id": "/dummy_groups/4", - "type": "DummyGroup", - "attributes": { - "_id": 4, - "foo": "Foo #13", - "bar": "Bar #13", - "baz": "Baz #13" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of resource with relation - Given there are 1 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/dummies/1?include=relatedDummy" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "_id": 1, - "name": "Dummy #1", - "alias": "Alias #0", - "foo": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #1", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 1, - "symfony": "symfony", - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of resources from path - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies/1?include=relatedDummy.thirdLevel.fourthLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "_id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - ] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "Hello", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": { - "type": "FourthLevel", - "id": "/fourth_levels/1" - } - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - ] - } - } - }, - { - "id": "/fourth_levels/1", - "type": "FourthLevel", - "attributes": { - "_id": 1, - "level": 4 - }, - "relationships": { - "badThirdLevel": { - "data": [] - } - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of resources from path with collection - Given there is a dummy object with 3 relatedDummies and their thirdLevel - When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "_id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "RelatedDummy #1", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - ] - } - } - }, - { - "id": "/related_dummies/2", - "type": "RelatedDummy", - "attributes": { - "_id": 2, - "name": "RelatedDummy #2", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/2" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/2", - "type": "ThirdLevel", - "attributes": { - "_id": 2, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - ] - } - } - }, - { - "id": "/related_dummies/3", - "type": "RelatedDummy", - "attributes": { - "_id": 3, - "name": "RelatedDummy #3", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/3" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/3", - "type": "ThirdLevel", - "attributes": { - "_id": 3, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - } - } - } - ] - } - """ - - @createSchema - Scenario: Do not include the requested resource - Given there is a RelatedOwningDummy object with OneToOne relation - When I send a "GET" request to "/dummies/1?include=relatedOwningDummy.ownedDummy" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "_id": 1, - "name": "plop", - "alias": null, - "foo": null - }, - "relationships": { - "relatedDummy": { - "data": [] - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": { - "type": "RelatedOwningDummy", - "id": "/related_owning_dummies/1" - } - } - } - }, - "included": [ - { - "id": "/related_owning_dummies/1", - "type": "RelatedOwningDummy", - "attributes": { - "name": null, - "_id": 1 - }, - "relationships": { - "ownedDummy": { - "data": { - "type": "Dummy", - "id": "/dummies/1" - } - } - } - } - ] - } - """ - - @createSchema - Scenario: Do not include resources multiple times - Given there is a dummy object with 3 relatedDummies with same thirdLevel - When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "_id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "RelatedDummy #1", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - } - } - }, - { - "id": "/related_dummies/2", - "type": "RelatedDummy", - "attributes": { - "_id": 2, - "name": "RelatedDummy #2", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/related_dummies/3", - "type": "RelatedDummy", - "attributes": { - "_id": 3, - "name": "RelatedDummy #3", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - } - ] - } - """ - - - @createSchema - Scenario: Request inclusion of a related resources on collection - Given there are 3 dummy property objects - When I send a "GET" request to "/dummy_properties?include=group" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "links": { - "self": "/dummy_properties?include=group" - }, - "meta": { - "totalItems": 3, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": "NameConverted #1" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - }, - { - "id": "/dummy_properties/2", - "type": "DummyProperty", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2", - "name_converted": "NameConverted #2" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/2" - } - }, - "groups": { - "data": [] - } - } - }, - { - "id": "/dummy_properties/3", - "type": "DummyProperty", - "attributes": { - "_id": 3, - "foo": "Foo #3", - "bar": "Bar #3", - "baz": "Baz #3", - "name_converted": "NameConverted #3" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/3" - } - }, - "groups": { - "data": [] - } - } - } - ], - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1" - } - }, - { - "id": "/dummy_groups/2", - "type": "DummyGroup", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2" - } - }, - { - "id": "/dummy_groups/3", - "type": "DummyGroup", - "attributes": { - "_id": 3, - "foo": "Foo #3", - "bar": "Bar #3", - "baz": "Baz #3" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of a related resources on collection should not duplicated included object - Given there are 3 dummy property objects with a shared group - When I send a "GET" request to "/dummy_properties?include=group" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "links": { - "self": "/dummy_properties?include=group" - }, - "meta": { - "totalItems": 3, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - }, - { - "id": "/dummy_properties/2", - "type": "DummyProperty", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - }, - { - "id": "/dummy_properties/3", - "type": "DummyProperty", - "attributes": { - "_id": 3, - "foo": "Foo #3", - "bar": "Bar #3", - "baz": "Baz #3", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - } - ], - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #shared", - "bar": "Bar #shared", - "baz": "Baz #shared" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of a related resources on collection should not duplicated included object - Given there are 2 dummy property objects with different number of related groups - When I send a "GET" request to "/dummy_properties?include=groups" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "links": { - "self": "/dummy_properties?include=groups" - }, - "meta": { - "totalItems": 2, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [{ - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": null - }, - "relationships": { - "groups": { - "data": [{ - "type": "DummyGroup", - "id": "/dummy_groups/1" - }] - } - } - }, { - "id": "/dummy_properties/2", - "type": "DummyProperty", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2", - "name_converted": null - }, - "relationships": { - "groups": { - "data": [{ - "type": "DummyGroup", - "id": "/dummy_groups/1" - }, { - "type": "DummyGroup", - "id": "/dummy_groups/2" - }] - } - } - }], - "included": [{ - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1" - } - }, { - "id": "/dummy_groups/2", - "type": "DummyGroup", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2" - } - }] - } - """ - - @createSchema - Scenario: Request inclusion from path of resource with relation - Given there are 3 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/dummies?include=relatedDummy.thirdLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "links": { - "self": "/dummies?include=relatedDummy.thirdLevel" - }, - "meta": { - "totalItems": 3, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "_id": 1, - "name": "Dummy #1", - "alias": "Alias #2", - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - { - "id": "/dummies/2", - "type": "Dummy", - "attributes": { - "_id": 2, - "name": "Dummy #2", - "alias": "Alias #1", - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - { - "id": "/dummies/3", - "type": "Dummy", - "attributes": { - "_id": 3, - "name": "Dummy #3", - "alias": "Alias #0", - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - } - ], - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "RelatedDummy #1", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - ] - } - } - }, - { - "id": "/related_dummies/2", - "type": "RelatedDummy", - "attributes": { - "_id": 2, - "name": "RelatedDummy #2", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/2" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/2", - "type": "ThirdLevel", - "attributes": { - "_id": 2, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - ] - } - } - }, - { - "id": "/related_dummies/3", - "type": "RelatedDummy", - "attributes": { - "_id": 3, - "name": "RelatedDummy #3", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/3" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/3", - "type": "ThirdLevel", - "attributes": { - "_id": 3, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - } - } - } - ] - } - """ diff --git a/features/jsonld/absolute_url.feature b/features/jsonld/absolute_url.feature deleted file mode 100644 index 9770e6bbb77..00000000000 --- a/features/jsonld/absolute_url.feature +++ /dev/null @@ -1,83 +0,0 @@ -Feature: IRI should contain Absolute URL - In order to add detail to IRIs - Include the absolute url - - @createSchema - Scenario: I should be able to GET a collection of Objects with Absolute Urls - Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy - And I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "@context": "http://example.com/contexts/AbsoluteUrlDummy", - "@id": "http://example.com/absolute_url_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "http://example.com/absolute_url_dummies/1", - "@type": "AbsoluteUrlDummy", - "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", - "id": 1 - } - ], - "hydra:totalItems": 1 - } - - """ - - Scenario: I should be able to POST an object using an Absolute Url - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/absolute_url_relation_dummies" with body: - """ - { - "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "http://example.com/contexts/AbsoluteUrlRelationDummy", - "@id": "http://example.com/absolute_url_relation_dummies/2", - "@type": "AbsoluteUrlRelationDummy", - "absoluteUrlDummies": [], - "id": 2 - } - """ - - Scenario: I should be able to GET an Item with Absolute Urls - Given I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/absolute_url_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "http://example.com/contexts/AbsoluteUrlDummy", - "@id": "http://example.com/absolute_url_dummies/1", - "@type": "AbsoluteUrlDummy", - "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", - "id": 1 - } - """ - - Scenario: I should be able to GET resources with Absolute Urls - Given I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "@context": "http://example.com/contexts/AbsoluteUrlDummy", - "@id": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "http://example.com/absolute_url_dummies/1", - "@type": "AbsoluteUrlDummy", - "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", - "id": 1 - } - ], - "hydra:totalItems": 1 - } - """ diff --git a/features/jsonld/context.feature b/features/jsonld/context.feature deleted file mode 100644 index 49d76b7e3b6..00000000000 --- a/features/jsonld/context.feature +++ /dev/null @@ -1,88 +0,0 @@ -Feature: JSON-LD contexts generation - In order to have an hypermedia, Linked Data enabled API - As a client software developer - I need to access to a JSON-LD context describing data types - - Scenario: Retrieve Entrypoint context - When I send a "GET" request to "/contexts/Entrypoint" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "@context.@vocab" should be equal to "http://example.com/docs.jsonld#" - And the JSON node "@context.hydra" should be equal to "http://www.w3.org/ns/hydra/core#" - And the JSON node "@context.dummy.@id" should be equal to "Entrypoint/dummy" - And the JSON node "@context.dummy.@type" should be equal to "@id" - - Scenario: Retrieve Dummy context - When I send a "GET" request to "/contexts/Dummy" - 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 a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "description": "https://schema.org/description", - "dummy": "Dummy/dummy", - "dummyBoolean": "Dummy/dummyBoolean", - "dummyDate": "https://schema.org/DateTime", - "dummyFloat": "Dummy/dummyFloat", - "dummyPrice": "Dummy/dummyPrice", - "relatedDummy": { - "@id": "Dummy/relatedDummy", - "@type": "@id" - }, - "relatedDummies": { - "@id": "Dummy/relatedDummies", - "@type": "@id" - }, - "jsonData": "Dummy/jsonData", - "arrayData": "Dummy/arrayData", - "name_converted": "Dummy/name_converted", - "name": "https://schema.org/name", - "alias": "https://schema.org/alternateName", - "foo": "Dummy/foo" - } - } - """ - - Scenario: Retrieve context of an object with an embed relation - When I send a "GET" request to "/contexts/RelationEmbedder" - 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": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "paris": "RelationEmbedder/paris", - "krondstadt": "RelationEmbedder/krondstadt", - "anotherRelated": "RelationEmbedder/anotherRelated", - "related": "RelationEmbedder/related" - } - } - """ - - Scenario: Retrieve Dummy with extended jsonld context - When I send a "GET" request to "/contexts/JsonldContextDummy" - 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": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "person": { - "@id": "https://example.com/id", - "@type": "@id", - "foo": "bar" - } - } - } - """ diff --git a/features/jsonld/disable_id_generation.feature b/features/jsonld/disable_id_generation.feature deleted file mode 100644 index 0e396ebe7ad..00000000000 --- a/features/jsonld/disable_id_generation.feature +++ /dev/null @@ -1,9 +0,0 @@ -Feature: Disable Id generation on anonymous resource collections - - @!mongodb - @createSchema - Scenario: Get embed collection without ids - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/disable_id_generation_collection" - Then the response status code should be 200 - Then the JSON node "disableIdGenerationItems[0].@id" should not exist diff --git a/features/jsonld/getter_setter_renaming.feature b/features/jsonld/getter_setter_renaming.feature deleted file mode 100644 index d5f876918b8..00000000000 --- a/features/jsonld/getter_setter_renaming.feature +++ /dev/null @@ -1,25 +0,0 @@ -Feature: Resource should contain one field for each property - In order to use API resource - As a developer - I need to have one field exposed for each property (which take getter/setter name) - - @createSchema - Scenario: I should be able to POST a new entity - When 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 "/entity_with_renamed_getter_and_setters" with body: - """ - { - "firstnameOnly": "Sarah" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "/contexts/EntityWithRenamedGetterAndSetter", - "@id": "/entity_with_renamed_getter_and_setters", - "@type": "EntityWithRenamedGetterAndSetter", - "firstnameOnly": "Sarah" - } - """ diff --git a/features/jsonld/inheritance.feature b/features/jsonld/inheritance.feature deleted file mode 100644 index 0008392cf54..00000000000 --- a/features/jsonld/inheritance.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Inheritance with correct IRIs - In order to fix (https://github.com/api-platform/core/issues/5438) - - Scenario: Get the collection of people with its employees - When I add "Accept" header equal to "application/json" - And I send a "GET" request to "/people_5438" - Then print last JSON response - - Scenario: Get the collection of people with its employees - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/people_5438" - Then print last JSON response diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature deleted file mode 100644 index 121f3050061..00000000000 --- a/features/jsonld/input_output.feature +++ /dev/null @@ -1,454 +0,0 @@ -Feature: JSON-LD DTO input and output - In order to use a hypermedia 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/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - Scenario: Create a resource with a custom Input - When I send a "POST" request to "/dummy_dto_customs" with body: - """ - { - "foo": "test", - "bar": 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/DummyDtoCustom", - "@id": "/dummy_dto_customs/1", - "@type": "DummyDtoCustom", - "lorem": "test", - "ipsum": "1", - "id": 1 - } - """ - - @createSchema - Scenario: Get an item with a custom output - Given there is a DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "foo": "CustomOutputDto/foo", - "bar": "CustomOutputDto/bar" - }, - "@type": "CustomOutputDto", - "foo": "test", - "bar": 1 - } - """ - - @createSchema - Scenario: Get a collection with a custom output - Given there are 2 DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output" - 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 a superset of: - """ - { - "@context": "/contexts/DummyDtoCustom", - "@id": "/dummy_dto_custom_output", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@type": "CustomOutputDto", - "foo": "test", - "bar": 1 - }, - { - "@type": "CustomOutputDto", - "foo": "test", - "bar": 2 - } - ], - "hydra:totalItems": 2 - } - """ - - @createSchema - Scenario: Create a DummyDtoCustom object without output - When I send a "POST" request to "/dummy_dto_custom_post_without_output" with body: - """ - { - "lorem": "test", - "ipsum": "1" - } - """ - Then the response status code should be 204 - And the response should be empty - - @createSchema - Scenario: Create and update a DummyInputOutput - When I send a "POST" request to "/dummy_dto_input_outputs" with body: - """ - { - "foo": "test", - "bar": 1 - } - """ - Then the response status code should be 201 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "test", - "relatedDummies": [] - } - """ - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_dto_input_outputs/1" with body: - """ - { - "foo": "test", - "bar": 2 - } - """ - Then the response status code should be 200 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 2, - "bat": "test", - "relatedDummies": [] - } - """ - - @!mongodb - @createSchema - Scenario: Use DTO with relations on User - When I send a "POST" request to "/users" with body: - """ - { - "username": "soyuka", - "plainPassword": "a real password", - "email": "soyuka@example.com" - } - """ - Then the response status code should be 201 - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/users/recover/1" with body: - """ - { - "user": "/users/1" - } - """ - Then the response status code should be 200 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "dummy": "RecoverPasswordOutput/dummy" - }, - "@type": "RecoverPasswordOutput", - "dummy": "/dummies/1" - } - """ - - @createSchema - @controller - Scenario: Create a resource with no input - When I send a "POST" request to "/dummy_dto_no_inputs" - 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 a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "test", - "relatedDummies": [] - } - """ - - @controller - Scenario: Update a resource with no input - When I send a "POST" request to "/dummy_dto_no_inputs/1/double_bat" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "testtest", - "relatedDummies": [] - } - """ - - @!mongodb - Scenario: Use messenger with an input where the handler gives a synchronous result - When I send a "POST" request to "/messenger_with_inputs" with body: - """ - { - "var": "test" - } - """ - 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/MessengerWithInput", - "@id": "/messenger_with_inputs/1", - "@type": "MessengerWithInput", - "id": 1, - "name": "test" - } - """ - - @!mongodb - Scenario: Use messenger with an input where the handler gives a synchronous Response result - When I send a "POST" request to "/messenger_with_responses" with body: - """ - { - "var": "test" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": 123 - } - """ - - @createSchema - Scenario: Initialize input data with a DataTransformerInitializer - Given there is an InitializeInput object with id 1 - When I send a "PUT" request to "/initialize_inputs/1" with body: - """ - { - "name": "La peste" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/InitializeInput", - "@id": "/initialize_inputs/1", - "@type": "InitializeInput", - "id": 1, - "manager": "Orwell", - "name": "La peste" - } - """ - - Scenario: Create a resource with a custom Input - When I send a "POST" request to "/dummy_dto_customs" with body: - """ - { - "foo": "test", - "bar": "test" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the JSON node "detail" should be equal to "The input data is misformatted." - - @!mongodb - Scenario: Reset password through an input DTO without DataTransformer - When I send a "POST" request to "/user-reset-password" 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/ld+json; charset=utf-8" - And the JSON node "email" should be equal to "user@example.com" - - @!mongodb - Scenario: Reset password with an invalid payload through an input DTO without DataTransformer - And I send a "POST" request to "/user-reset-password" with body: - """ - { - "email": "this is not an email" - } - """ - Then the response status code should be 422 - And the response should be in JSON - - @v3 - Scenario: Get a collection with a custom output and without item operations, from a resource without identifier - When I send a "GET" request to "/dummy_collection_dtos" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/DummyCollectionDto$"}, - "@id": {"pattern": "^/dummy_collection_dtos$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "foo", "bar"], - "properties": { - "@id": {"pattern": "^/.well-known/genid/.+$"}, - "@type": {"pattern": "^DummyCollectionDtoOutput$"}, - "foo": {"type": "string"}, - "bar": {"type": "integer"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - @v3 - Scenario: Get a collection with a custom output and itemUriTemplate, from a resource without identifier - When I send a "GET" request to "/dummy_foo_collection_dtos" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then print last JSON response - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/DummyFooCollectionDto$"}, - "@id": {"pattern": "^/dummy_foo_collection_dtos$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "foo", "bar"], - "properties": { - "@id": {"pattern": "/dummy_foos/bar"}, - "@type": {"pattern": "^DummyFooCollectionDto$"}, - "foo": {"type": "string"}, - "bar": {"type": "integer"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - @v3 - # Cannot generate proper IRI because DTO does not support output yet - # todo Change member IRI to `/dummy_id_collection_dtos/.+` once DTO support @ApiProperty - Scenario: Get a collection with a custom output and without item operations, from a resource with an identifier - When I send a "GET" request to "/dummy_id_collection_dtos" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/DummyIdCollectionDto$"}, - "@id": {"pattern": "^/dummy_id_collection_dtos$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "foo", "bar"], - "properties": { - "@id": {"pattern": "^/.well-known/genid/.+$"}, - "@type": {"pattern": "^DummyIdCollectionDtoOutput$"}, - "id": {"type": "integer"}, - "foo": {"type": "string"}, - "bar": {"type": "integer"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ diff --git a/features/jsonld/interface_as_resource.feature b/features/jsonld/interface_as_resource.feature deleted file mode 100644 index 7236ace7ae7..00000000000 --- a/features/jsonld/interface_as_resource.feature +++ /dev/null @@ -1,57 +0,0 @@ -Feature: JSON-LD using interface as resource - In order to use interface as resource - As a developer - I should be able to serialize objects of an interface as 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 - Scenario: Retrieve a taxon - Given there is the following taxon: - """ - { - "code": "WONDERFUL_TAXON" - } - """ - When I send a "GET" request to "/taxa/WONDERFUL_TAXON" - 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/Taxon", - "@id": "/taxa/WONDERFUL_TAXON", - "@type": "Taxon", - "code": "WONDERFUL_TAXON" - } - """ - - Scenario: Retrieve a product with a main taxon - Given there is the following product: - """ - { - "code": "GREAT_PRODUCT", - "mainTaxon": "/taxa/WONDERFUL_TAXON" - } - """ - When I send a "GET" request to "/products/GREAT_PRODUCT" - 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/Product", - "@id": "/products/GREAT_PRODUCT", - "@type": "Product", - "code": "GREAT_PRODUCT", - "mainTaxon": { - "@id": "/taxa/WONDERFUL_TAXON", - "@type": "Taxon", - "code": "WONDERFUL_TAXON" - } - } - """ diff --git a/features/jsonld/interface_dto_output.feature b/features/jsonld/interface_dto_output.feature deleted file mode 100644 index 9c56f8e57bb..00000000000 --- a/features/jsonld/interface_dto_output.feature +++ /dev/null @@ -1,11 +0,0 @@ -Feature: Resource should be able to take interface as output value - - @createSchema - Scenario: I should be able to GET a collection of objects - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - When I send a "GET" request to "/entity_with_dto_outputs" - And the JSON node "hydra:member[0].name" should exist - And the JSON node "hydra:member[0].@type" should exist - And the JSON node "hydra:member[0].@id" should exist - And the JSON node "hydra:member[0].city" should not exist diff --git a/features/jsonld/iri_only.feature b/features/jsonld/iri_only.feature deleted file mode 100644 index 170e7f69f86..00000000000 --- a/features/jsonld/iri_only.feature +++ /dev/null @@ -1,95 +0,0 @@ -Feature: JSON-LD using iri_only parameter - In order to improve Vulcain support - As a Vulcain user and as a developer - I should be able to only get an IRI list when I ask a resource. - - Scenario Outline: Retrieve Dummy's resource context with iri_only - When I send a "GET" request to "" - 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": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "hydra:member": { - "@type": "@id" - } - } - } - """ - Examples: - | uri | - | /contexts/IriOnlyDummy | - | /contexts/IriOnlyDummy.jsonld | - - Scenario: Retrieve Dummy's resource context with invalid format returns an error - When I send a "GET" request to "/contexts/IriOnlyDummy.json" - Then the response status code should be 404 - - @createSchema - Scenario: Retrieve Dummies with iri_only and jsonld_embed_context - Given there are 3 iriOnlyDummies - When I send a "GET" request to "/iri_only_dummies" - 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": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "hydra:member": { - "@type": "@id" - } - }, - "@id": "/iri_only_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - "/iri_only_dummies/1", - "/iri_only_dummies/2", - "/iri_only_dummies/3" - ], - "hydra:totalItems": 3 - } - """ - - @createSchema - Scenario: Retrieve Resource with uriTemplate collection Property - Given there are propertyCollectionIriOnly with relations - When I send a "GET" request to "/property_collection_iri_onlies" - 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 a superset of: - """ - { - "hydra:member": [ - { - "@id": "/property_collection_iri_onlies/1", - "@type": "PropertyCollectionIriOnly", - "propertyCollectionIriOnlyRelation": "/property-collection-relations", - "iterableIri": "/parent/1/another-collection-operations", - "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" - } - ] - } - """ - When I send a "GET" request to "/property_collection_iri_onlies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/PropertyCollectionIriOnly", - "@id": "/property_collection_iri_onlies/1", - "@type": "PropertyCollectionIriOnly", - "propertyCollectionIriOnlyRelation": "/property-collection-relations", - "iterableIri": "/parent/1/another-collection-operations", - "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" - } - """ diff --git a/features/jsonld/json_serializable.feature b/features/jsonld/json_serializable.feature deleted file mode 100644 index 57cb57de510..00000000000 --- a/features/jsonld/json_serializable.feature +++ /dev/null @@ -1,72 +0,0 @@ -Feature: JSON-LD using JsonSerializable types - In order to use JsonSerializable in resource and non-resource types - As a developer - I should be able to serialize objects of JsonSerializable type. - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - Scenario: Create a Content - When I send a "POST" request to "/contents" with body: - """ - { - "contentType": "homepage", - "fields": [ - { - "name": "title", - "value": "Labore reprehenderit dolorem repellendus asperiores." - }, - { - "name": "content", - "value": "Minus sed repellendus corporis nemo. Aut aut veniam at aut aliquid. Architecto tempora quia neque numquam voluptas sint est delectus.\n\nUnde voluptatem animi non ut aut dicta. Omnis vero dolorum aliquid laudantium magni asperiores. Et tempora eveniet soluta modi occaecati.\n\nEa dolorum tenetur voluptatum temporibus illo fuga. Quibusdam et doloribus debitis omnis sed. Tempora in aperiam ullam non odit. Praesentium sunt accusantium dolorem commodi labore eum nostrum quia." - } - ] - } - """ - 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/Content", - "@id": "/contents/1", - "@type": "Content", - "id": 1, - "contentType": "homepage", - "status": { - "key": "DRAFT", - "value": "draft" - }, - "fieldValues": { - "title": "Labore reprehenderit dolorem repellendus asperiores.", - "content": "Minus sed repellendus corporis nemo. Aut aut veniam at aut aliquid. Architecto tempora quia neque numquam voluptas sint est delectus.\n\nUnde voluptatem animi non ut aut dicta. Omnis vero dolorum aliquid laudantium magni asperiores. Et tempora eveniet soluta modi occaecati.\n\nEa dolorum tenetur voluptatum temporibus illo fuga. Quibusdam et doloribus debitis omnis sed. Tempora in aperiam ullam non odit. Praesentium sunt accusantium dolorem commodi labore eum nostrum quia." - } - } - """ - - Scenario: Retrieve a Content - When I send a "GET" request to "/contents/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Content", - "@id": "/contents/1", - "@type": "Content", - "id": 1, - "contentType": "homepage", - "status": { - "key": "DRAFT", - "value": "draft" - }, - "fieldValues": { - "title": "Labore reprehenderit dolorem repellendus asperiores.", - "content": "Minus sed repellendus corporis nemo. Aut aut veniam at aut aliquid. Architecto tempora quia neque numquam voluptas sint est delectus.\n\nUnde voluptatem animi non ut aut dicta. Omnis vero dolorum aliquid laudantium magni asperiores. Et tempora eveniet soluta modi occaecati.\n\nEa dolorum tenetur voluptatum temporibus illo fuga. Quibusdam et doloribus debitis omnis sed. Tempora in aperiam ullam non odit. Praesentium sunt accusantium dolorem commodi labore eum nostrum quia." - } - } - """ diff --git a/features/jsonld/max_depth.feature b/features/jsonld/max_depth.feature deleted file mode 100644 index 58c2e5c1249..00000000000 --- a/features/jsonld/max_depth.feature +++ /dev/null @@ -1,43 +0,0 @@ -Feature: Max depth handling - In order to handle MaxDepthDummy resources - As a developer - I need to be able to limit their depth with @maxDepth - - @createSchema - Scenario: Create a resource with 1 level of descendants - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/max_depth_eager_dummies" with body: - """ - { - "name": "level 1", - "child": { - "name": "level 2" - } - } - """ - 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 the JSON node "child" should exist - Then the JSON node "child.name" should be equal to "level 2" - - Scenario: Add a 2nd level of descendants - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/max_depth_eager_dummies" with body: - """ - { - "name": "level 1", - "child": { - "name": "level 2", - "child": { - "name": "level 3" - } - } - } - """ - And 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 the JSON node "child" should exist - Then the JSON node "child.name" should be equal to "level 2" - Then the JSON node "child.child" should not exist diff --git a/features/jsonld/network_path.feature b/features/jsonld/network_path.feature deleted file mode 100644 index 6d486390e3f..00000000000 --- a/features/jsonld/network_path.feature +++ /dev/null @@ -1,86 +0,0 @@ -Feature: IRI should contain network path - In order to add detail to IRIs - Include the network path - - @createSchema - Scenario: I should be able to GET a collection of objects with network paths - Given there are 1 networkPathDummy objects with a related networkPathRelationDummy - And I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/network_path_dummies" - And the JSON should be equal to: - """ - { - "@context": "//example.com/contexts/NetworkPathDummy", - "@id": "//example.com/network_path_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "//example.com/network_path_dummies/1", - "@type": "NetworkPathDummy", - "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", - "id": 1 - } - ], - "hydra:totalItems": 1 - } - - """ - - Scenario: I should be able to POST an object using a network path - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/network_path_relation_dummies" with body: - """ - { - "network_path_dummies": "//example.com/network_path_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "//example.com/contexts/NetworkPathRelationDummy", - "@id": "//example.com/network_path_relation_dummies/2", - "@type": "NetworkPathRelationDummy", - "networkPathDummies": [], - "id": 2 - } - """ - - Scenario: I should be able to GET an Item with network paths - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/network_path_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "//example.com/contexts/NetworkPathDummy", - "@id": "//example.com/network_path_dummies/1", - "@type": "NetworkPathDummy", - "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", - "id": 1 - } - """ - - Scenario: I should be able to GET resources with network paths - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" - And the JSON should be equal to: - """ - { - "@context": "//example.com/contexts/NetworkPathDummy", - "@id": "//example.com/network_path_relation_dummies/1/network_path_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "//example.com/network_path_dummies/1", - "@type": "NetworkPathDummy", - "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", - "id": 1 - } - ], - "hydra:totalItems": 1 - } - """ diff --git a/features/jsonld/no_output.feature b/features/jsonld/no_output.feature deleted file mode 100644 index 09b7a41919a..00000000000 --- a/features/jsonld/no_output.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Disable Id generation on anonymous resource collections - - @!mongodb - Scenario: Post to an output false should not generate an IRI - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/no_iri_messages" with body: - """ - {} - """ - Then the response status code should be 202 diff --git a/features/jsonld/non_resource.feature b/features/jsonld/non_resource.feature deleted file mode 100644 index 083770c9a28..00000000000 --- a/features/jsonld/non_resource.feature +++ /dev/null @@ -1,145 +0,0 @@ -Feature: JSON-LD non-resource handling - 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 - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/contain_non_resources/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/ContainNonResource", - "@id": "/contain_non_resources/1", - "@type": "ContainNonResource", - "id": 1, - "nested": { - "@id": "/contain_non_resources/1-nested", - "@type": "ContainNonResource", - "id": "1-nested", - "nested": null, - "notAResource": { - "@type": "NotAResource", - "foo": "f2", - "bar": "b2" - } - }, - "notAResource": { - "@type": "NotAResource", - "foo": "f1", - "bar": "b1" - } - } - """ - And the JSON node "notAResource.@id" should exist - - @createSchema - Scenario: Get a resource containing a raw object with selected properties - Given there are 1 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/contain_non_resources/1?properties[]=id&properties[nested][notAResource][]=foo&properties[notAResource][]=bar" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/ContainNonResource", - "@id": "/contain_non_resources/1", - "@type": "ContainNonResource", - "id": 1, - "nested": { - "@id": "/contain_non_resources/1-nested", - "@type": "ContainNonResource", - "notAResource": { - "@type": "NotAResource", - "foo": "f2" - } - }, - "notAResource": { - "@type": "NotAResource", - "bar": "b1" - } - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that has a non-resource relation. - When I send a "POST" request to "/non_relation_resources" with body: - """ - { - "relation": { - "foo": "test" - } - } - """ - 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 a superset of: - """ - { - "@context": "/contexts/NonRelationResource", - "@id": "/non_relation_resources/1", - "@type": "NonRelationResource", - "relation": { - "@type": "NonResourceClass", - "foo": "test" - }, - "id": 1 - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that contains a stdClass object. - When I send a "POST" request to "/plain_object_dummies" with body: - """ - { - "content": "{\"fields\":{\"title\":{\"value\":\"\"},\"images\":[{\"id\":0,\"categoryId\":0,\"uri\":\"/api/pictures\",\"resource\":\"{}\",\"description\":\"\",\"alt\":\"\",\"type\":\"picture\",\"text\":\"\",\"src\":\"\"}],\"alternativeAudio\":{},\"caption\":\"\"},\"showCaption\":false,\"alternativeContent\":false,\"alternativeAudioContent\":false,\"blockLayout\":\"default\"}" - } - """ - 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 a superset of: - """ - { - "@context": "/contexts/PlainObjectDummy", - "@id": "/plain_object_dummies/1", - "@type": "PlainObjectDummy", - "data": { - "fields": [], - "showCaption": false, - "alternativeContent": false, - "alternativeAudioContent": false, - "blockLayout": "default" - }, - "id": 1 - } - """ - - @php8 - Scenario: Get a generated id - When I send a "GET" request to "/genids/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "totalPrice.@id" should not exist - - @!mongodb - @createSchema - Scenario: Get a resource using entityClass with a DateTime attribute - Given there is a resource using entityClass with a DateTime attribute - When I send a "GET" request to "/EntityClassWithDateTime/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "start" should exist diff --git a/tests/Fixtures/TestBundle/ApiResource/DtoOutput.php b/tests/Fixtures/TestBundle/ApiResource/DtoOutput.php new file mode 100644 index 00000000000..f91efee8fce --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DtoOutput.php @@ -0,0 +1,21 @@ + + * + * 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; + +final class DtoOutput +{ + public function __construct(public readonly string $name = '') + { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/EntityWithDtoOutput.php b/tests/Fixtures/TestBundle/ApiResource/EntityWithDtoOutput.php deleted file mode 100644 index 77355d097c3..00000000000 --- a/tests/Fixtures/TestBundle/ApiResource/EntityWithDtoOutput.php +++ /dev/null @@ -1,71 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; - -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\Operation; - -#[ApiResource] -#[GetCollection(output: DtoInterface::class, provider: [self::class, 'provide'])] -class EntityWithDtoOutput -{ - private string $name; - - private string $city; - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } - - public function getCity(): string - { - return $this->city; - } - - public function setCity(string $city): void - { - $this->city = $city; - } - - public static function provide(Operation $operation, array $uriVariables = [], array $context = []): array - { - return [ - new DtoOutput('Sarah'), - ]; - } -} - -interface DtoInterface -{ - public function getName(): string; -} - -class DtoOutput implements DtoInterface -{ - public function __construct(private readonly string $name) - { - } - - public function getName(): string - { - return $this->name; - } -} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlChild.php b/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlChild.php new file mode 100644 index 00000000000..c6cc558408f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlChild.php @@ -0,0 +1,78 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'HalAbsoluteUrlChild', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new GetCollection( + uriTemplate: '/hal_absolute_url_children', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_absolute_url_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_absolute_url_children', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/hal_absolute_url_parents/{parentId}/children', + uriVariables: [ + 'parentId' => new Link(fromClass: AbsoluteUrlParent::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class AbsoluteUrlChild +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?AbsoluteUrlParent $parent = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->parent = AbsoluteUrlParent::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlParent.php b/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlParent.php new file mode 100644 index 00000000000..1fabe052fc2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlParent.php @@ -0,0 +1,60 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'HalAbsoluteUrlParent', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new Get( + uriTemplate: '/hal_absolute_url_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_absolute_url_parents', + processor: [self::class, 'process'], + ), + ], +)] +class AbsoluteUrlParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var AbsoluteUrlChild[] */ + public array $children = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/CollectionPagedResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/CollectionPagedResource.php new file mode 100644 index 00000000000..cf21afde041 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/CollectionPagedResource.php @@ -0,0 +1,122 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; + +#[ApiResource( + shortName: 'HalCollectionPaged', + paginationItemsPerPage: 3, + paginationClientItemsPerPage: true, + paginationClientEnabled: true, + paginationClientPartial: true, + operations: [ + new GetCollection( + uriTemplate: '/hal_collection_paged', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class CollectionPagedResource +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $name = ''; + + public function __construct(int $id) + { + $this->id = $id; + $this->name = "Dummy #{$id}"; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 10)); + $filters = $context['filters'] ?? []; + + if (isset($filters['id']) && '' !== $filters['id']) { + $needle = (string) $filters['id']; + $items = array_values(array_filter($items, static fn (self $r) => (string) $r->id === $needle || "/dummies/{$r->id}" === $needle)); + } + + if (isset($filters['name']) && '' !== $filters['name']) { + $needle = (string) $filters['name']; + $items = array_values(array_filter($items, static fn (self $r) => $r->name === $needle)); + } + + $page = (int) ($filters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + $itemsPerPage = (int) ($filters['itemsPerPage'] ?? 3); + if ($itemsPerPage < 0) { + $itemsPerPage = 3; + } + + $paginationDisabled = '0' === (string) ($filters['pagination'] ?? '1'); + if ($paginationDisabled) { + return new ArrayPaginator($items, 0, \count($items)); + } + + $partial = '1' === (string) ($filters['partial'] ?? ''); + if ($partial) { + return new HalCollectionPartialPaginator(\array_slice($items, ($page - 1) * $itemsPerPage, $itemsPerPage), $page, $itemsPerPage); + } + + return new ArrayPaginator($items, ($page - 1) * $itemsPerPage, $itemsPerPage); + } +} + +/** + * @internal + */ +final class HalCollectionPartialPaginator implements \IteratorAggregate, PartialPaginatorInterface, HasNextPagePaginatorInterface +{ + /** @param list $items */ + public function __construct(private readonly array $items, private readonly int $page, private readonly int $itemsPerPage) + { + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->items); + } + + public function count(): int + { + return \count($this->items); + } + + public function getCurrentPage(): float + { + return (float) $this->page; + } + + public function getItemsPerPage(): float + { + return (float) $this->itemsPerPage; + } + + public function hasNextPage(): bool + { + return \count($this->items) === $this->itemsPerPage; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/CustomOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/CustomOutputResource.php new file mode 100644 index 00000000000..d2ea8af0e7e --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/CustomOutputResource.php @@ -0,0 +1,65 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'HalCustomOutput', + operations: [ + new GetCollection( + uriTemplate: '/hal_custom_outputs', + output: CustomOutputDto::class, + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_custom_outputs/{id}', + uriVariables: ['id'], + output: CustomOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +class CustomOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = 'origin'; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): CustomOutputDto + { + return new CustomOutputDto(); + } + + public static function provideCollection(): array + { + $a = new CustomOutputDto(); + $b = new CustomOutputDto(); + $b->bar = 2; + + return [$a, $b]; + } +} + +final class CustomOutputDto +{ + public string $foo = 'test'; + + public int $bar = 1; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/HalRelatedResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/HalRelatedResource.php new file mode 100644 index 00000000000..3bbf5d2bfc7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/HalRelatedResource.php @@ -0,0 +1,49 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'HalRelatedResource', + operations: [ + new Get( + uriTemplate: '/hal_related_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class HalRelatedResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public string $symfony = 'symfony'; + + #[ApiProperty(readableLink: true)] + public ?HalThirdLevel $thirdLevel = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->thirdLevel = HalThirdLevel::provide(new Get(), ['id' => 1], $context); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/HalThirdLevel.php b/tests/Fixtures/TestBundle/ApiResource/Hal/HalThirdLevel.php new file mode 100644 index 00000000000..26273af698a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/HalThirdLevel.php @@ -0,0 +1,45 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'HalThirdLevel', + operations: [ + new Get( + uriTemplate: '/hal_third_levels/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class HalThirdLevel +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public int $level = 3; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/MaxDepthResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/MaxDepthResource.php new file mode 100644 index 00000000000..723e7959196 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/MaxDepthResource.php @@ -0,0 +1,79 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\MaxDepth; + +#[ApiResource( + shortName: 'HalMaxDepth', + normalizationContext: ['groups' => ['hal_max_depth'], 'enable_max_depth' => true], + denormalizationContext: ['groups' => ['hal_max_depth'], 'enable_max_depth' => true], + operations: [ + new Post( + uriTemplate: '/hal_max_depth_resources', + processor: [self::class, 'process'], + ), + new Put( + uriTemplate: '/hal_max_depth_resources/{id}', + uriVariables: ['id'], + extraProperties: ['standard_put' => false], + provider: [self::class, 'provide'], + processor: [self::class, 'process'], + ), + ], +)] +class MaxDepthResource +{ + #[ApiProperty(identifier: true)] + #[Groups(['hal_max_depth'])] + public ?int $id = null; + + #[Groups(['hal_max_depth'])] + public ?string $name = null; + + #[Groups(['hal_max_depth'])] + #[MaxDepth(1)] + public ?self $child = null; + + public static function process(self $data): self + { + $data->id = 1; + if ($data->child) { + $data->child->id = 2; + if ($data->child->child) { + $data->child->child->id = 3; + } + } + + return $data; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $root = new self(); + $root->id = (int) ($uriVariables['id'] ?? 1); + $root->name = 'level 1'; + $root->child = new self(); + $root->child->id = 2; + $root->child->name = 'level 2'; + + return $root; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathParent.php b/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathParent.php new file mode 100644 index 00000000000..2a493620098 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathParent.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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'HalNetworkPathParent', + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new Get( + uriTemplate: '/hal_network_path_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_network_path_parents', + processor: [self::class, 'process'], + ), + ], +)] +class NetworkPathParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathResource.php new file mode 100644 index 00000000000..6f9bdbc609c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathResource.php @@ -0,0 +1,78 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'HalNetworkPathChild', + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new GetCollection( + uriTemplate: '/hal_network_path_children', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_network_path_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_network_path_children', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/hal_network_path_parents/{parentId}/children', + uriVariables: [ + 'parentId' => new Link(fromClass: NetworkPathParent::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class NetworkPathResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?NetworkPathParent $parent = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->parent = NetworkPathParent::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/NonResourceContainer.php b/tests/Fixtures/TestBundle/ApiResource/Hal/NonResourceContainer.php new file mode 100644 index 00000000000..3ed76c09814 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/NonResourceContainer.php @@ -0,0 +1,69 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'HalNonResourceContainer', + normalizationContext: ['groups' => ['hal_non_resource']], + operations: [ + new Get( + uriTemplate: '/hal_non_resource_containers/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class NonResourceContainer +{ + #[ApiProperty(identifier: true)] + #[Groups(['hal_non_resource'])] + public string $id; + + #[Groups(['hal_non_resource'])] + public ?self $nested = null; + + #[Groups(['hal_non_resource'])] + public ?NonResourceClass $notAResource = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $root = new self(); + $root->id = (string) ($uriVariables['id'] ?? '1'); + $root->notAResource = new NonResourceClass('f1', 'b1'); + + $nested = new self(); + $nested->id = $root->id.'-nested'; + $nested->notAResource = new NonResourceClass('f2', 'b2'); + $root->nested = $nested; + + return $root; + } +} + +final class NonResourceClass +{ + public function __construct( + #[Groups(['hal_non_resource'])] + public string $foo, + #[Groups(['hal_non_resource'])] + public string $bar, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemRelation.php b/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemRelation.php new file mode 100644 index 00000000000..503b2684d8f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemRelation.php @@ -0,0 +1,45 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'HalProblemRelation', + operations: [ + new Get( + uriTemplate: '/hal_problem_relations/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class ProblemRelation +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $name = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemResource.php new file mode 100644 index 00000000000..119d0fbe407 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemResource.php @@ -0,0 +1,46 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'HalProblem', + operations: [ + new Post( + uriTemplate: '/hal_problems', + processor: [self::class, 'process'], + ), + ], +)] +class ProblemResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[Assert\NotBlank] + public ?string $name = null; + + public ?ProblemRelation $relatedDummy = null; + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/RelationEmbedder.php b/tests/Fixtures/TestBundle/ApiResource/Hal/RelationEmbedder.php new file mode 100644 index 00000000000..f9cf45bca0c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/RelationEmbedder.php @@ -0,0 +1,86 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; + +#[ApiResource( + shortName: 'HalRelationEmbedder', + operations: [ + new GetCollection( + uriTemplate: '/hal_relation_embedders', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_relation_embedders/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_relation_embedders', + processor: [self::class, 'process'], + ), + new Put( + uriTemplate: '/hal_relation_embedders/{id}', + uriVariables: ['id'], + extraProperties: ['standard_put' => false], + provider: [self::class, 'provide'], + processor: [self::class, 'process'], + ), + new Patch( + uriTemplate: '/hal_relation_embedders/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + processor: [self::class, 'process'], + ), + ], +)] +class RelationEmbedder +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public string $krondstadt = 'Krondstadt'; + + #[ApiProperty(readableLink: true)] + public ?HalRelatedResource $related = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->related = HalRelatedResource::provide(new Get(), ['id' => 1], $context); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/UriTemplateCar.php b/tests/Fixtures/TestBundle/ApiResource/Hal/UriTemplateCar.php new file mode 100644 index 00000000000..3554581ff88 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/UriTemplateCar.php @@ -0,0 +1,85 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'HalUriTemplateCar', + operations: [ + new GetCollection( + uriTemplate: '/hal_uri_template_cars', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_uri_template_cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_uri_template_cars', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/hal_uri_template_brands/renault/cars', + itemUriTemplate: '/hal_uri_template_brands/renault/cars/{id}', + provider: [self::class, 'provideCollection'], + ), + new Post( + uriTemplate: '/hal_uri_template_brands/renault/cars', + itemUriTemplate: '/hal_uri_template_brands/renault/cars/{id}', + processor: [self::class, 'process'], + ), + new Get( + uriTemplate: '/hal_uri_template_brands/renault/cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class UriTemplateCar +{ + #[ApiProperty(identifier: true)] + public string $id; + + public string $owner; + + public function __construct(string $id = '1', string $owner = 'Vincent') + { + $this->id = $id; + $this->owner = $owner; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self((string) ($uriVariables['id'] ?? '1'), 'Vincent'); + } + + public static function provideCollection(): array + { + return [new self('1'), new self('2')]; + } + + public static function process(self $data): self + { + $data->id = '42'; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlDummy.php new file mode 100644 index 00000000000..eea9cfd9250 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlDummy.php @@ -0,0 +1,68 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonApiAbsoluteUrlDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + paginationItemsPerPage: 3, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_absolute_url_dummies', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_absolute_url_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new GetCollection( + uriTemplate: '/jsonapi_absolute_url_relation_dummies/{relationId}/absolute_url_dummies', + uriVariables: [ + 'relationId' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class AbsoluteUrlDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?AbsoluteUrlRelationDummy $absoluteUrlRelationDummy = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->absoluteUrlRelationDummy = AbsoluteUrlRelationDummy::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlRelationDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlRelationDummy.php new file mode 100644 index 00000000000..4977228cfe0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlRelationDummy.php @@ -0,0 +1,61 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonApiAbsoluteUrlRelationDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new Get( + uriTemplate: '/jsonapi_absolute_url_relation_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_absolute_url_relation_dummies', + processor: [self::class, 'process'], + ), + ], +)] +class AbsoluteUrlRelationDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var AbsoluteUrlDummy[] */ + public array $absoluteUrlDummies = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/CircularReference.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/CircularReference.php new file mode 100644 index 00000000000..710bf44d8a1 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/CircularReference.php @@ -0,0 +1,60 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonApiCircularReference', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get( + uriTemplate: '/jsonapi_circular_references/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class CircularReference +{ + #[ApiProperty(identifier: true)] + public int $id; + + public ?CircularReference $parent = null; + + /** @var CircularReference[] */ + public array $children = []; + + public function __construct(int $id = 1) + { + $this->id = $id; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $first = new self(1); + $second = new self(2); + + $first->parent = $first; + $second->parent = $first; + $first->children = [$first, $second]; + + $id = (int) ($uriVariables['id'] ?? 1); + + return 2 === $id ? $second : $first; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/CustomOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/CustomOutputResource.php new file mode 100644 index 00000000000..15abc2ac3d7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/CustomOutputResource.php @@ -0,0 +1,66 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonApiCustomOutput', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_custom_outputs', + output: CustomOutputDto::class, + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_custom_outputs/{id}', + uriVariables: ['id'], + output: CustomOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +class CustomOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = 'origin'; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): CustomOutputDto + { + return new CustomOutputDto(); + } + + public static function provideCollection(): array + { + $a = new CustomOutputDto(); + $b = new CustomOutputDto(); + $b->bar = 2; + + return [$a, $b]; + } +} + +final class CustomOutputDto +{ + public string $foo = 'test'; + + public int $bar = 1; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/EntrypointDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/EntrypointDummy.php new file mode 100644 index 00000000000..569d11c03fa --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/EntrypointDummy.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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonApiEntrypointDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_entrypoint_dummies', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_entrypoint_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class EntrypointDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function provideCollection(): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php new file mode 100644 index 00000000000..cd8e73e65b0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php @@ -0,0 +1,60 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; + +#[ApiResource( + shortName: 'JsonApiErrorProblem', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Post( + uriTemplate: '/jsonapi_validation_problem', + processor: [self::class, 'processValidation'], + ), + new Post( + uriTemplate: '/jsonapi_exception_problem', + processor: [self::class, 'processBadRequest'], + ), + ], +)] +class ErrorProblem +{ + public string $name = ''; + + public static function processValidation(): void + { + $root = new self(); + $violation = new ConstraintViolation( + 'This value should not be blank.', + null, + [], + $root, + 'name', + null, + ); + + throw new ValidationException(new ConstraintViolationList([$violation])); + } + + public static function processBadRequest(): void + { + throw new BadRequestHttpException(); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringDummy.php new file mode 100644 index 00000000000..ae158bdd3a8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringDummy.php @@ -0,0 +1,75 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonApiFilteringDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationItemsPerPage: 3, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_filtering_dummies', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class FilteringDummy +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $name; + + public ?string $dummyDate; + + public function __construct(int $id, int $total = 30) + { + $this->id = $id; + $this->name = "Dummy #{$id}"; + // Last dummy has null date — match behat's thereAreDummyObjectsWithDummyDate. + $this->dummyDate = $id === $total ? null : \sprintf('2015-04-%02dT00:00:00+00:00', $id); + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 30)); + $filters = $context['filters'] ?? []; + + if (isset($filters['name']) && '' !== $filters['name']) { + $needle = strtolower((string) $filters['name']); + $items = array_values(array_filter($items, static fn (self $r): bool => str_contains(strtolower($r->name), $needle))); + } + + if (isset($filters['dummyDate']['after'])) { + $threshold = new \DateTimeImmutable((string) $filters['dummyDate']['after']); + $items = array_values(array_filter($items, static function (self $r) use ($threshold): bool { + return null !== $r->dummyDate && new \DateTimeImmutable($r->dummyDate) >= $threshold; + })); + } + + $page = (int) ($filters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + $itemsPerPage = 3; + + return new ArrayPaginator($items, ($page - 1) * $itemsPerPage, $itemsPerPage); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringProperty.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringProperty.php new file mode 100644 index 00000000000..23cd58a245a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringProperty.php @@ -0,0 +1,56 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonApiFilteringProperty', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationEnabled: false, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_filtering_properties', + filters: ['dummy_property.property'], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class FilteringProperty +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $foo; + + public string $bar; + + public string $group; + + public function __construct(int $id) + { + $this->id = $id; + $this->foo = "Foo #{$id}"; + $this->bar = "Bar #{$id}"; + $this->group = "Group #{$id}"; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): array + { + return [new self(1), new self(2)]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathDummy.php new file mode 100644 index 00000000000..0a9a1c9e60b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathDummy.php @@ -0,0 +1,68 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonApiNetworkPathDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + paginationItemsPerPage: 3, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_network_path_dummies', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_network_path_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new GetCollection( + uriTemplate: '/jsonapi_network_path_relation_dummies/{relationId}/network_path_dummies', + uriVariables: [ + 'relationId' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class NetworkPathDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?NetworkPathRelationDummy $networkPathRelationDummy = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->networkPathRelationDummy = NetworkPathRelationDummy::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathRelationDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathRelationDummy.php new file mode 100644 index 00000000000..a9b7de624bd --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathRelationDummy.php @@ -0,0 +1,61 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonApiNetworkPathRelationDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new Get( + uriTemplate: '/jsonapi_network_path_relation_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_network_path_relation_dummies', + processor: [self::class, 'process'], + ), + ], +)] +class NetworkPathRelationDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var NetworkPathDummy[] */ + public array $networkPathDummies = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonRelationResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonRelationResource.php new file mode 100644 index 00000000000..807e668e524 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonRelationResource.php @@ -0,0 +1,59 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonApiNonRelationResource', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get( + uriTemplate: '/jsonapi_non_relation_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_non_relation_resources', + processor: [self::class, 'process'], + ), + ], +)] +class NonRelationResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?NonRelationPayload $relation = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} + +final class NonRelationPayload +{ + public string $foo = ''; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonResourceContainer.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonResourceContainer.php new file mode 100644 index 00000000000..ffadad66a1d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonResourceContainer.php @@ -0,0 +1,70 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonApiNonResourceContainer', + formats: ['jsonapi' => ['application/vnd.api+json']], + normalizationContext: ['groups' => ['jsonapi_non_resource']], + operations: [ + new Get( + uriTemplate: '/jsonapi_non_resource_containers/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class NonResourceContainer +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonapi_non_resource'])] + public string $id; + + #[Groups(['jsonapi_non_resource'])] + public ?self $nested = null; + + #[Groups(['jsonapi_non_resource'])] + public ?NonResourceClass $notAResource = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $root = new self(); + $root->id = (string) ($uriVariables['id'] ?? '1'); + $root->notAResource = new NonResourceClass('f1', 'b1'); + + $nested = new self(); + $nested->id = $root->id.'-nested'; + $nested->notAResource = new NonResourceClass('f2', 'b2'); + $root->nested = $nested; + + return $root; + } +} + +final class NonResourceClass +{ + public function __construct( + #[Groups(['jsonapi_non_resource'])] + public string $foo, + #[Groups(['jsonapi_non_resource'])] + public string $bar, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/OrderingDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/OrderingDummy.php new file mode 100644 index 00000000000..c6dcfb9310b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/OrderingDummy.php @@ -0,0 +1,75 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonApiOrderingDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationItemsPerPage: 30, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_ordering_dummies', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class OrderingDummy +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $name; + + public string $description; + + public function __construct(int $id) + { + $this->id = $id; + $this->name = "Dummy #{$id}"; + // Even-id dummies share description "even"; odd dummies "odd". + // Sorting by description,-id puts evens first (desc within group): 30, 28, 26... + $this->description = 0 === $id % 2 ? 'even' : 'odd'; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 30)); + $filters = $context['filters'] ?? []; + $order = $filters['order'] ?? []; + + if ($order) { + usort($items, static function (self $a, self $b) use ($order): int { + foreach ($order as $field => $direction) { + $cmp = $a->{$field} <=> $b->{$field}; + if ('desc' === strtolower((string) $direction)) { + $cmp = -$cmp; + } + if (0 !== $cmp) { + return $cmp; + } + } + + return 0; + }); + } + + return new ArrayPaginator($items, 0, \count($items)); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/PaginationDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PaginationDummy.php new file mode 100644 index 00000000000..cbfb9870075 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PaginationDummy.php @@ -0,0 +1,71 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonApiPaginationDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationItemsPerPage: 3, + paginationClientItemsPerPage: true, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_pagination_dummies', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class PaginationDummy +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 10)); + $filters = $context['filters'] ?? []; + + $rawPage = $filters['page'] ?? 1; + if (!is_numeric($rawPage)) { + throw new InvalidArgumentException('Page must be a positive integer.'); + } + $page = (int) $rawPage; + if ($page < 1) { + throw new InvalidArgumentException('Page must be a positive integer.'); + } + + $itemsPerPage = (int) ($filters['itemsPerPage'] ?? 3); + if ($itemsPerPage < 1) { + $itemsPerPage = 3; + } + + if ($page > intdiv(\PHP_INT_MAX, $itemsPerPage) + 1) { + throw new InvalidArgumentException('Page is out of range.'); + } + $offset = ($page - 1) * $itemsPerPage; + + return new ArrayPaginator($items, $offset, $itemsPerPage); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/PlainObjectResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PlainObjectResource.php new file mode 100644 index 00000000000..07766fa2c0a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PlainObjectResource.php @@ -0,0 +1,59 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonApiPlainObjectResource', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get( + uriTemplate: '/jsonapi_plain_object_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_plain_object_resources', + processor: [self::class, 'process'], + ), + ], +)] +class PlainObjectResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $content = null; + + public ?\stdClass $data = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + if (null !== $data->content) { + $data->data = json_decode($data->content); + } + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/UriTemplateCar.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/UriTemplateCar.php new file mode 100644 index 00000000000..3dc2accb6f0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/UriTemplateCar.php @@ -0,0 +1,86 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonApiUriTemplateCar', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_uri_template_cars', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_uri_template_cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_uri_template_cars', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/jsonapi_uri_template_brands/renault/cars', + itemUriTemplate: '/jsonapi_uri_template_brands/renault/cars/{id}', + provider: [self::class, 'provideCollection'], + ), + new Post( + uriTemplate: '/jsonapi_uri_template_brands/renault/cars', + itemUriTemplate: '/jsonapi_uri_template_brands/renault/cars/{id}', + processor: [self::class, 'process'], + ), + new Get( + uriTemplate: '/jsonapi_uri_template_brands/renault/cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class UriTemplateCar +{ + #[ApiProperty(identifier: true)] + public string $id; + + public string $owner; + + public function __construct(string $id = '1', string $owner = 'Vincent') + { + $this->id = $id; + $this->owner = $owner; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self((string) ($uriVariables['id'] ?? '1'), 'Vincent'); + } + + public static function provideCollection(): array + { + return [new self('1'), new self('2')]; + } + + public static function process(self $data): self + { + $data->id = '42'; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsolutePagedResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsolutePagedResource.php new file mode 100644 index 00000000000..8b13c4ad818 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsolutePagedResource.php @@ -0,0 +1,51 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonLdAbsolutePaged', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + paginationItemsPerPage: 3, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_absolute_paged', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class AbsolutePagedResource +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): ArrayPaginator + { + $page = (int) ($context['filters']['page'] ?? 1); + $items = array_map(static fn (int $i): self => new self($i), range(1, 30)); + + return new ArrayPaginator($items, ($page - 1) * 3, 3); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlChild.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlChild.php new file mode 100644 index 00000000000..e91632ec292 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlChild.php @@ -0,0 +1,78 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonLdAbsoluteUrlChild', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_absolute_url_children', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_absolute_url_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_absolute_url_children', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/jsonld_absolute_url_parents/{parentId}/children', + uriVariables: [ + 'parentId' => new Link(fromClass: AbsoluteUrlParent::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class AbsoluteUrlChild +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?AbsoluteUrlParent $parent = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->parent = AbsoluteUrlParent::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlParent.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlParent.php new file mode 100644 index 00000000000..24d8c1ffde9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlParent.php @@ -0,0 +1,60 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonLdAbsoluteUrlParent', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new Get( + uriTemplate: '/jsonld_absolute_url_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_absolute_url_parents', + processor: [self::class, 'process'], + ), + ], +)] +class AbsoluteUrlParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var AbsoluteUrlChild[] */ + public array $children = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionNoPrefix.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionNoPrefix.php new file mode 100644 index 00000000000..0cfb17e2080 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionNoPrefix.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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdCollectionNoPrefix', + normalizationContext: ['hydra_prefix' => false], + operations: [ + new GetCollection( + uriTemplate: '/jsonld_collection_no_prefix', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class CollectionNoPrefix +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } + + public static function provideCollection(): array + { + return [new self(1), new self(2)]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionPagedResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionPagedResource.php new file mode 100644 index 00000000000..c6a3142f18c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionPagedResource.php @@ -0,0 +1,124 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; + +#[ApiResource( + shortName: 'JsonLdCollectionPaged', + paginationItemsPerPage: 3, + paginationClientItemsPerPage: true, + paginationClientEnabled: true, + paginationClientPartial: true, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_collection_paged', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class CollectionPagedResource +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $name = ''; + + public function __construct(int $id) + { + $this->id = $id; + $this->name = "Dummy #{$id}"; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 30)); + $filters = $context['filters'] ?? []; + + if (isset($filters['id']) && '' !== $filters['id']) { + $needle = (string) $filters['id']; + $items = array_values(array_filter($items, static fn (self $r) => (string) $r->id === $needle || "/dummies/{$r->id}" === $needle)); + } + + if (isset($filters['name']) && '' !== $filters['name']) { + $needle = (string) $filters['name']; + $items = array_values(array_filter($items, static fn (self $r) => $r->name === $needle)); + } + + $page = (int) ($filters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + $itemsPerPage = (int) ($filters['itemsPerPage'] ?? 3); + if ($itemsPerPage < 0) { + $itemsPerPage = 3; + } + + $paginationDisabled = '0' === (string) ($filters['pagination'] ?? '1'); + if ($paginationDisabled) { + return new ArrayPaginator($items, 0, \count($items)); + } + + $partial = '1' === (string) ($filters['partial'] ?? ''); + if ($partial) { + return new CollectionPartialPaginator(\array_slice($items, ($page - 1) * $itemsPerPage, $itemsPerPage), $page, $itemsPerPage); + } + + return new ArrayPaginator($items, ($page - 1) * $itemsPerPage, $itemsPerPage); + } +} + +/** + * Implements only the partial paginator contract so hydra:view drops first/last. + * + * @internal + */ +final class CollectionPartialPaginator implements \IteratorAggregate, PartialPaginatorInterface, HasNextPagePaginatorInterface +{ + /** @param list $items */ + public function __construct(private readonly array $items, private readonly int $page, private readonly int $itemsPerPage) + { + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->items); + } + + public function count(): int + { + return \count($this->items); + } + + public function getCurrentPage(): float + { + return (float) $this->page; + } + + public function getItemsPerPage(): float + { + return (float) $this->itemsPerPage; + } + + public function hasNextPage(): bool + { + return \count($this->items) === $this->itemsPerPage; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomInputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomInputResource.php new file mode 100644 index 00000000000..89d6c9b4ee4 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomInputResource.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'JsonLdCustomInput', + operations: [ + new Get( + uriTemplate: '/jsonld_custom_inputs/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_custom_inputs', + input: CustomInputDto::class, + processor: [self::class, 'process'], + ), + ], +)] +class CustomInputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $lorem = null; + + public ?string $ipsum = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->lorem = 'test'; + $r->ipsum = '1'; + + return $r; + } + + public static function process(CustomInputDto $data): self + { + $r = new self(); + $r->id = 1; + $r->lorem = $data->foo; + $r->ipsum = (string) $data->bar; + + return $r; + } +} + +final class CustomInputDto +{ + public ?string $foo = null; + + #[Assert\Type('integer')] + public ?int $bar = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomOutputResource.php new file mode 100644 index 00000000000..d50170ac5fb --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomOutputResource.php @@ -0,0 +1,65 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdCustomOutput', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_custom_outputs', + output: CustomOutputDto::class, + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_custom_outputs/{id}', + uriVariables: ['id'], + output: CustomOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +class CustomOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = 'origin'; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): CustomOutputDto + { + return new CustomOutputDto(); + } + + public static function provideCollection(): array + { + $a = new CustomOutputDto(); + $b = new CustomOutputDto(); + $b->bar = 2; + + return [$a, $b]; + } +} + +final class CustomOutputDto +{ + public string $foo = 'test'; + + public int $bar = 1; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DateTimeOnlyResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DateTimeOnlyResource.php new file mode 100644 index 00000000000..4c4fad27482 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DateTimeOnlyResource.php @@ -0,0 +1,46 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdDateTimeResource', + operations: [ + new Get( + uriTemplate: '/jsonld_datetime_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class DateTimeOnlyResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?\DateTimeInterface $start = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->start = new \DateTimeImmutable('2024-01-01T00:00:00+00:00'); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DisableIdGenAnonymous.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DisableIdGenAnonymous.php new file mode 100644 index 00000000000..fca70abf105 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DisableIdGenAnonymous.php @@ -0,0 +1,47 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; + +#[Get( + shortName: 'JsonLdDisableIdGenAnonymous', + uriTemplate: '/jsonld_disable_id_gen_anonymous', + provider: [self::class, 'provide'], +)] +class DisableIdGenAnonymous +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var array */ + #[ApiProperty(genId: false)] + public array $items; + + public static function provide(): self + { + $a = new self(); + $a->items = [new DisableIdGenItem('one'), new DisableIdGenItem('two')]; + + return $a; + } +} + +class DisableIdGenItem +{ + public function __construct(public string $title) + { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyCollectionDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyCollectionDto.php new file mode 100644 index 00000000000..b885400b3e9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyCollectionDto.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\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdDummyCollectionDto', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_dummy_collection_dtos', + output: DummyCollectionDtoOutput::class, + provider: [self::class, 'provideCollection'], + ), + ], +)] +class DummyCollectionDto +{ + public string $foo = ''; + + public int $bar = 0; + + public static function provideCollection(): array + { + $a = new DummyCollectionDtoOutput(); + $a->foo = 'foo'; + $a->bar = 1; + + $b = new DummyCollectionDtoOutput(); + $b->foo = 'foo'; + $b->bar = 2; + + return [$a, $b]; + } +} + +final class DummyCollectionDtoOutput +{ + public string $foo = ''; + + public int $bar = 0; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyFooCollectionDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyFooCollectionDto.php new file mode 100644 index 00000000000..a63be9bc25b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyFooCollectionDto.php @@ -0,0 +1,69 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdDummyFooCollectionDto', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_dummy_foo_collection_dtos', + itemUriTemplate: '/jsonld_dummy_foos/bar', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_dummy_foos/bar', + provider: [self::class, 'provide'], + ), + ], +)] +class DummyFooCollectionDto +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $foo = ''; + + public int $bar = 0; + + public static function provideCollection(): array + { + $a = new self(); + $a->id = 1; + $a->foo = 'foo'; + $a->bar = 1; + + $b = new self(); + $b->id = 2; + $b->foo = 'foo'; + $b->bar = 2; + + return [$a, $b]; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = 1; + $r->foo = 'foo'; + $r->bar = 1; + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyIdCollectionDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyIdCollectionDto.php new file mode 100644 index 00000000000..3538035ef9b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyIdCollectionDto.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\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdDummyIdCollectionDto', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_dummy_id_collection_dtos', + output: DummyIdCollectionDtoOutput::class, + provider: [self::class, 'provideCollection'], + ), + ], +)] +class DummyIdCollectionDto +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $foo = ''; + + public int $bar = 0; + + public static function provideCollection(): array + { + $a = new DummyIdCollectionDtoOutput(); + $a->id = 1; + $a->foo = 'foo'; + $a->bar = 1; + + $b = new DummyIdCollectionDtoOutput(); + $b->id = 2; + $b->foo = 'foo'; + $b->bar = 2; + + return [$a, $b]; + } +} + +final class DummyIdCollectionDtoOutput +{ + public ?int $id = null; + + public string $foo = ''; + + public int $bar = 0; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/GenIdFalseProperty.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/GenIdFalseProperty.php new file mode 100644 index 00000000000..402acc72c31 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/GenIdFalseProperty.php @@ -0,0 +1,58 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdGenIdFalseProperty', + operations: [ + new Get( + uriTemplate: '/jsonld_genid_false_properties/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class GenIdFalseProperty +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + #[ApiProperty(genId: false)] + public GenIdMonetaryAmount $totalPrice; + + public function __construct() + { + $this->totalPrice = new GenIdMonetaryAmount(42); + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } +} + +final class GenIdMonetaryAmount +{ + public function __construct(public readonly float $value) + { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsDeprecated.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsDeprecated.php new file mode 100644 index 00000000000..0e2e42fc5d3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsDeprecated.php @@ -0,0 +1,50 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdHydraDocsDeprecated', + deprecationReason: 'This resource is deprecated.', + operations: [ + new GetCollection(uriTemplate: '/jsonld_hydra_docs_deprecated', provider: [self::class, 'provideCollection']), + new Get(uriTemplate: '/jsonld_hydra_docs_deprecated/{id}', uriVariables: ['id'], provider: [self::class, 'provide']), + ], +)] +class HydraDocsDeprecated +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[ApiProperty(deprecationReason: 'This field is deprecated.')] + public ?string $deprecatedField = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function provideCollection(): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsRelated.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsRelated.php new file mode 100644 index 00000000000..9ca3c8df240 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsRelated.php @@ -0,0 +1,49 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdHydraDocsRelated', + types: ['https://schema.org/Product'], + operations: [ + new GetCollection(uriTemplate: '/jsonld_hydra_docs_related', provider: [self::class, 'provideCollection']), + new Get(uriTemplate: '/jsonld_hydra_docs_related/{id}', uriVariables: ['id'], provider: [self::class, 'provide']), + ], +)] +class HydraDocsRelated +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = ''; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function provideCollection(): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsResource.php new file mode 100644 index 00000000000..5390c2cde92 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsResource.php @@ -0,0 +1,71 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Put; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * A docs sample. + */ +#[ApiResource( + shortName: 'JsonLdHydraDocs', + operations: [ + new Get(uriTemplate: '/jsonld_hydra_docs/{id}', uriVariables: ['id'], provider: [self::class, 'provide']), + new GetCollection(uriTemplate: '/jsonld_hydra_docs', provider: [self::class, 'provideCollection']), + new Put(uriTemplate: '/jsonld_hydra_docs/{id}', uriVariables: ['id'], processor: [self::class, 'process']), + new Delete(uriTemplate: '/jsonld_hydra_docs/{id}', uriVariables: ['id'], processor: [self::class, 'process']), + ], +)] +class HydraDocsResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + /** + * The doc resource name. + */ + #[ApiProperty(iris: ['https://schema.org/name'])] + #[Assert\NotBlank] + public string $name = ''; + + public ?HydraDocsRelated $related = null; + + /** @var array */ + public array $relateds = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function provideCollection(): array + { + return []; + } + + public static function process(mixed $data): mixed + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraErrorResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraErrorResource.php new file mode 100644 index 00000000000..fafb888c1f0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraErrorResource.php @@ -0,0 +1,70 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; + +#[ApiResource( + shortName: 'JsonLdHydraError', + operations: [ + new Post( + uriTemplate: '/jsonld_hydra_errors_bad_request', + processor: [self::class, 'throwBadRequest'], + ), + new Post( + uriTemplate: '/jsonld_hydra_errors_validation', + processor: [self::class, 'throwValidation'], + ), + new Post( + uriTemplate: '/jsonld_hydra_errors_no_prefix', + normalizationContext: ['hydra_prefix' => false], + processor: [self::class, 'throwBadRequest'], + ), + new Patch( + uriTemplate: '/jsonld_hydra_errors_patch_only', + processor: [self::class, 'throwBadRequest'], + ), + ], +)] +class HydraErrorResource +{ + public static function throwBadRequest(): void + { + throw new BadRequestHttpException(); + } + + public static function throwValidation(): void + { + $list = new ConstraintViolationList([ + new ConstraintViolation( + 'This value should not be blank.', + null, + [], + null, + 'name', + null, + null, + 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ), + ]); + + throw new ValidationException($list); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/InputOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InputOutputResource.php new file mode 100644 index 00000000000..77ec9d1061d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InputOutputResource.php @@ -0,0 +1,117 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; + +#[ApiResource( + shortName: 'JsonLdInputOutputResource', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_input_outputs', + output: InputOutputDto::class, + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_input_outputs/{id}', + uriVariables: ['id'], + output: InputOutputDto::class, + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_input_outputs', + input: InputOutputInputDto::class, + output: InputOutputDto::class, + processor: [self::class, 'process'], + ), + new Put( + uriTemplate: '/jsonld_input_outputs/{id}', + uriVariables: ['id'], + input: InputOutputInputDto::class, + output: InputOutputDto::class, + provider: [self::class, 'provide'], + processor: [self::class, 'process'], + ), + ], +)] +class InputOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $foo = null; + + public ?int $bar = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->foo = 'test'; + $r->bar = 1; + + return $r; + } + + public static function provideCollection(): array + { + $a = new self(); + $a->id = 1; + $a->foo = 'test'; + $a->bar = 1; + $b = new self(); + $b->id = 2; + $b->foo = 'test'; + $b->bar = 2; + + return [$a, $b]; + } + + public static function process(InputOutputInputDto $data, Operation $operation, array $uriVariables = [], array $context = []): InputOutputDto + { + $out = new InputOutputDto(); + $out->id = (int) ($uriVariables['id'] ?? 1); + $out->bat = $data->foo; + $out->baz = $data->bar; + $out->relatedDummies = []; + + return $out; + } +} + +final class InputOutputInputDto +{ + public ?string $foo = null; + + public ?int $bar = null; +} + +final class InputOutputDto +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?int $baz = null; + + public ?string $bat = null; + + /** @var list */ + public array $relatedDummies = []; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceDtoOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceDtoOutputResource.php new file mode 100644 index 00000000000..d51e2f82856 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceDtoOutputResource.php @@ -0,0 +1,63 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdInterfaceDtoOutput', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_interface_dto_outputs', + output: InterfaceDtoOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +final class InterfaceDtoOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = ''; + + public string $city = ''; + + public static function provide(): array + { + return [new InterfaceDtoOutputImpl(1, 'Sarah')]; + } +} + +interface InterfaceDtoOutputDto +{ + public function getName(): string; +} + +final class InterfaceDtoOutputImpl implements InterfaceDtoOutputDto +{ + public function __construct( + #[ApiProperty(identifier: true)] + public readonly int $id, + private readonly string $name, + ) { + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxon.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxon.php new file mode 100644 index 00000000000..823314be202 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxon.php @@ -0,0 +1,55 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonLdInterfaceTaxon', + normalizationContext: ['groups' => ['jsonld_taxon_read']], + operations: [ + new Get( + uriTemplate: '/jsonld_interface_taxa/{code}', + uriVariables: ['code'], + provider: [InterfaceTaxonImpl::class, 'provideTaxon'], + ), + ], +)] +interface InterfaceTaxon +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_taxon_read', 'jsonld_product_read'])] + public function getCode(): ?string; +} + +final class InterfaceTaxonImpl implements InterfaceTaxon +{ + public function __construct(public string $code = '') + { + } + + public function getCode(): string + { + return $this->code; + } + + public static function provideTaxon(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self($uriVariables['code'] ?? 'WONDERFUL_TAXON'); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxonProduct.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxonProduct.php new file mode 100644 index 00000000000..be7f062fdb3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxonProduct.php @@ -0,0 +1,50 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonLdInterfaceTaxonProduct', + normalizationContext: ['groups' => ['jsonld_product_read']], + operations: [ + new Get( + uriTemplate: '/jsonld_interface_taxon_products/{code}', + uriVariables: ['code'], + provider: [self::class, 'provide'], + ), + ], +)] +final class InterfaceTaxonProduct +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_product_read'])] + public string $code; + + #[Groups(['jsonld_product_read'])] + public ?InterfaceTaxon $mainTaxon = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $product = new self(); + $product->code = $uriVariables['code'] ?? 'GREAT_PRODUCT'; + $product->mainTaxon = new InterfaceTaxonImpl('WONDERFUL_TAXON'); + + return $product; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/IriOnlyResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/IriOnlyResource.php new file mode 100644 index 00000000000..eda110387fa --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/IriOnlyResource.php @@ -0,0 +1,63 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdIriOnlyResource', + normalizationContext: ['iri_only' => true, 'jsonld_embed_context' => true], + operations: [ + new GetCollection( + uriTemplate: '/jsonld_iri_only_resources', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_iri_only_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class IriOnlyResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $foo = ''; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->foo = "foo {$r->id}"; + + return $r; + } + + public static function provideCollection(): array + { + return array_map(static function (int $i): self { + $r = new self(); + $r->id = $i; + $r->foo = "foo {$i}"; + + return $r; + }, [1, 2, 3]); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php new file mode 100644 index 00000000000..18cd539f17a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php @@ -0,0 +1,70 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource( + shortName: 'JsonLdContextDummy', + provider: [self::class, 'provide'], + processor: [self::class, 'process'], +)] +class JsonLdContextDummy +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[ApiProperty(iris: ['https://schema.org/name'])] + public ?string $name = null; + + #[ApiProperty(iris: ['https://schema.org/alternateName'])] + public ?string $alias = null; + + #[ApiProperty(jsonldContext: ['@id' => 'https://example.com/id', '@type' => '@id', 'foo' => 'bar'])] + public ?string $person = null; + + public ?JsonLdContextRelation $related = null; + + /** + * Exercises the collection-valued relation context mapping. + * + * @var JsonLdContextRelation[] + */ + public array $relatedCollection = []; + + #[ApiProperty(readableLink: true)] + public ?JsonLdContextRelation $embedded = null; + + #[ApiProperty(iris: ['https://schema.org/DateTime'])] + public ?\DateTimeInterface $dummyDate = null; + + public ?array $arrayData = null; + + public mixed $jsonData = null; + + public ?string $nameConverted = null; + + public static function provide(): array + { + return []; + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextRelation.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextRelation.php new file mode 100644 index 00000000000..406baf1d149 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextRelation.php @@ -0,0 +1,37 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdContextRelation', + operations: [ + new GetCollection(uriTemplate: '/jsonld_context_relations', provider: [self::class, 'provide']), + ], +)] +class JsonLdContextRelation +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $name = null; + + public static function provide(): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonSerializableResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonSerializableResource.php new file mode 100644 index 00000000000..545990ce665 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonSerializableResource.php @@ -0,0 +1,103 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonLdJsonSerializable', + normalizationContext: ['groups' => ['jsonld_jss']], + operations: [ + new Get( + uriTemplate: '/jsonld_json_serializables/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_json_serializables', + provider: [self::class, 'provideNew'], + processor: [self::class, 'process'], + ), + ], +)] +class JsonSerializableResource implements \JsonSerializable +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_jss'])] + public ?int $id = null; + + #[Groups(['jsonld_jss'])] + public string $contentType = ''; + + /** @var array */ + #[Groups(['jsonld_jss'])] + public array $fieldValues = []; + + #[Groups(['jsonld_jss'])] + public JsonSerializableStatus $status; + + public function __construct() + { + $this->status = new JsonSerializableStatus('DRAFT', 'draft'); + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'contentType' => $this->contentType, + 'status' => $this->status, + 'fieldValues' => $this->fieldValues, + ]; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->contentType = 'homepage'; + $r->fieldValues = ['title' => 'hello']; + + return $r; + } + + public static function provideNew(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} + +final class JsonSerializableStatus implements \JsonSerializable +{ + public function __construct(public readonly string $key, public readonly string $value) + { + } + + public function jsonSerialize(): array + { + return ['key' => $this->key, 'value' => $this->value]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/MaxDepthResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/MaxDepthResource.php new file mode 100644 index 00000000000..1db06db1e0c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/MaxDepthResource.php @@ -0,0 +1,58 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\MaxDepth; + +#[ApiResource( + shortName: 'JsonLdMaxDepth', + normalizationContext: ['groups' => ['jsonld_max_depth'], 'enable_max_depth' => true], + denormalizationContext: ['groups' => ['jsonld_max_depth'], 'enable_max_depth' => true], + operations: [ + new Post( + uriTemplate: '/jsonld_max_depth_resources', + processor: [self::class, 'process'], + ), + ], +)] +class MaxDepthResource +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_max_depth'])] + public ?int $id = null; + + #[Groups(['jsonld_max_depth'])] + public ?string $name = null; + + #[Groups(['jsonld_max_depth'])] + #[MaxDepth(1)] + public ?self $child = null; + + public static function process(self $data): self + { + $data->id = 1; + if ($data->child) { + $data->child->id = 2; + if ($data->child->child) { + $data->child->child->id = 3; + } + } + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathParent.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathParent.php new file mode 100644 index 00000000000..10bc1f369bc --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathParent.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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonLdNetworkPathParent', + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new Get( + uriTemplate: '/jsonld_network_path_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_network_path_parents', + processor: [self::class, 'process'], + ), + ], +)] +class NetworkPathParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathResource.php new file mode 100644 index 00000000000..d30ec739fb0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathResource.php @@ -0,0 +1,78 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonLdNetworkPathChild', + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_network_path_children', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_network_path_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_network_path_children', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/jsonld_network_path_parents/{parentId}/children', + uriVariables: [ + 'parentId' => new Link(fromClass: NetworkPathParent::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class NetworkPathResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?NetworkPathParent $parent = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->parent = NetworkPathParent::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoInputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoInputResource.php new file mode 100644 index 00000000000..03bc78e0fab --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoInputResource.php @@ -0,0 +1,81 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdNoInput', + operations: [ + new Get( + uriTemplate: '/jsonld_no_inputs/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_no_inputs', + input: false, + processor: [self::class, 'create'], + ), + new Post( + uriTemplate: '/jsonld_no_inputs/{id}/double_bat', + uriVariables: ['id'], + input: false, + status: 200, + read: true, + provider: [self::class, 'provide'], + processor: [self::class, 'doubleBat'], + ), + ], +)] +class NoInputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?int $baz = null; + + public ?string $bat = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->baz = 1; + $r->bat = 'test'; + + return $r; + } + + public static function create(): self + { + $r = new self(); + $r->id = 1; + $r->baz = 1; + $r->bat = 'test'; + + return $r; + } + + public static function doubleBat(self $data): self + { + $data->bat = (string) $data->bat.$data->bat; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoOutputMessage.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoOutputMessage.php new file mode 100644 index 00000000000..d35a04b998f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoOutputMessage.php @@ -0,0 +1,40 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdNoOutputMessage', + operations: [ + new NotExposed(uriTemplate: '/jsonld_no_output_messages/{id}'), + new Post( + uriTemplate: '/jsonld_no_output_messages', + status: 202, + output: false, + processor: [self::class, 'process'], + ), + ], +)] +class NoOutputMessage +{ + public ?int $id = null; + + public static function process(mixed $data): mixed + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonRelationResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonRelationResource.php new file mode 100644 index 00000000000..e60af1ab9c1 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonRelationResource.php @@ -0,0 +1,58 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdNonRelationResource', + operations: [ + new Get( + uriTemplate: '/jsonld_non_relation_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_non_relation_resources', + processor: [self::class, 'process'], + ), + ], +)] +class NonRelationResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?NonRelationPayload $relation = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} + +final class NonRelationPayload +{ + public string $foo = ''; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php new file mode 100644 index 00000000000..076f8752819 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php @@ -0,0 +1,72 @@ + + * + * 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\JsonLd; + +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; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonLdNonResourceContainer', + normalizationContext: ['groups' => ['jsonld_non_resource']], + operations: [ + new Get( + uriTemplate: '/jsonld_non_resource_containers/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +#[ApiFilter(PropertyFilter::class)] +class NonResourceContainer +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_non_resource'])] + public string $id; + + #[Groups(['jsonld_non_resource'])] + public ?self $nested = null; + + #[Groups(['jsonld_non_resource'])] + public ?NonResourceClass $notAResource = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $root = new self(); + $root->id = (string) ($uriVariables['id'] ?? '1'); + $root->notAResource = new NonResourceClass('f1', 'b1'); + + $nested = new self(); + $nested->id = $root->id.'-nested'; + $nested->notAResource = new NonResourceClass('f2', 'b2'); + $root->nested = $nested; + + return $root; + } +} + +final class NonResourceClass +{ + public function __construct( + #[Groups(['jsonld_non_resource'])] + public string $foo, + #[Groups(['jsonld_non_resource'])] + public string $bar, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/PaginationCapped.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PaginationCapped.php new file mode 100644 index 00000000000..0b755a3cf5a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PaginationCapped.php @@ -0,0 +1,43 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\State\JsonLdPaginationCappedProvider; + +#[ApiResource( + shortName: 'JsonLdPaginationCapped', + paginationItemsPerPage: 3, + paginationMaximumItemsPerPage: 30, + paginationClientItemsPerPage: true, + paginationClientEnabled: true, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_pagination_capped', + provider: JsonLdPaginationCappedProvider::class, + ), + ], +)] +class PaginationCapped +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/PlainObjectResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PlainObjectResource.php new file mode 100644 index 00000000000..ce2f5358d64 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PlainObjectResource.php @@ -0,0 +1,58 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdPlainObjectResource', + operations: [ + new Get( + uriTemplate: '/jsonld_plain_object_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_plain_object_resources', + processor: [self::class, 'process'], + ), + ], +)] +class PlainObjectResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $content = null; + + public ?\stdClass $data = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + if (null !== $data->content) { + $data->data = json_decode($data->content); + } + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/PostNoOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PostNoOutputResource.php new file mode 100644 index 00000000000..279af7d2348 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PostNoOutputResource.php @@ -0,0 +1,39 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdPostNoOutput', + operations: [ + new Post( + uriTemplate: '/jsonld_post_no_output', + output: false, + processor: [self::class, 'process'], + ), + ], +)] +class PostNoOutputResource +{ + public ?string $lorem = null; + + public ?string $ipsum = null; + + public static function process(self $data): self + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/EntityWithRenamedGetterAndSetter.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/RenamedGetterSetter.php similarity index 63% rename from tests/Fixtures/TestBundle/ApiResource/EntityWithRenamedGetterAndSetter.php rename to tests/Fixtures/TestBundle/ApiResource/JsonLd/RenamedGetterSetter.php index 251453d2477..335f5aece95 100644 --- a/tests/Fixtures/TestBundle/ApiResource/EntityWithRenamedGetterAndSetter.php +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/RenamedGetterSetter.php @@ -11,15 +11,19 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Operation; -#[ApiResource(provider: [self::class, 'provide'])] -class EntityWithRenamedGetterAndSetter +#[ApiResource( + shortName: 'JsonLdRenamedGetterSetter', + provider: [self::class, 'provide'], + processor: [self::class, 'process'], +)] +class RenamedGetterSetter { - private string $name; + private string $name = ''; public function getFirstnameOnly(): string { @@ -33,6 +37,11 @@ public function setFirstnameOnly(string $name): void public static function provide(Operation $operation, array $uriVariables = [], array $context = []): array { - return $context; + return []; + } + + public static function process(mixed $data): mixed + { + return $data; } } diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/UriTemplateCar.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/UriTemplateCar.php new file mode 100644 index 00000000000..c5ed575dfc9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/UriTemplateCar.php @@ -0,0 +1,85 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdUriTemplateCar', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_uri_template_cars', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_uri_template_cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_uri_template_cars', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/jsonld_uri_template_brands/renault/cars', + itemUriTemplate: '/jsonld_uri_template_brands/renault/cars/{id}', + provider: [self::class, 'provideCollection'], + ), + new Post( + uriTemplate: '/jsonld_uri_template_brands/renault/cars', + itemUriTemplate: '/jsonld_uri_template_brands/renault/cars/{id}', + processor: [self::class, 'process'], + ), + new Get( + uriTemplate: '/jsonld_uri_template_brands/renault/cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class UriTemplateCar +{ + #[ApiProperty(identifier: true)] + public string $id; + + public string $owner; + + public function __construct(string $id = '1', string $owner = 'Vincent') + { + $this->id = $id; + $this->owner = $owner; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self((string) ($uriVariables['id'] ?? '1'), 'Vincent'); + } + + public static function provideCollection(): array + { + return [new self('1'), new self('2')]; + } + + public static function process(self $data): self + { + $data->id = '42'; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/Document/JsonldContextDummy.php b/tests/Fixtures/TestBundle/Document/JsonldContextDummy.php deleted file mode 100644 index ac8509cadf7..00000000000 --- a/tests/Fixtures/TestBundle/Document/JsonldContextDummy.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; - -use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; - -/** - * Jsonld Context Dummy. - */ -#[ApiResource] -#[ODM\Document] -class JsonldContextDummy -{ - /** - * @var int The id - */ - #[ApiProperty(identifier: true)] - #[ODM\Id(strategy: 'INCREMENT', type: 'int')] - private ?int $id = null; - - /** - * @var string The dummy person - */ - #[ApiProperty( - jsonldContext: ['@id' => 'https://example.com/id', '@type' => '@id', 'foo' => 'bar'] - )] - private $person; - - public function getId(): ?int - { - return $this->id; - } - - public function setPerson($person): void - { - $this->person = $person; - } - - public function getPerson() - { - return $this->person; - } -} diff --git a/tests/Fixtures/TestBundle/Document/MaxDepthEagerDummy.php b/tests/Fixtures/TestBundle/Document/MaxDepthEagerDummy.php deleted file mode 100644 index 6aa501f3adc..00000000000 --- a/tests/Fixtures/TestBundle/Document/MaxDepthEagerDummy.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; - -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -use Symfony\Component\Serializer\Attribute\Groups; -use Symfony\Component\Serializer\Attribute\MaxDepth; - -/** - * @author Brian Fox - */ -#[ApiResource(normalizationContext: ['groups' => ['default'], 'enable_max_depth' => true], denormalizationContext: ['groups' => ['default'], 'enable_max_depth' => true], graphQlOperations: [])] -#[ODM\Document] -class MaxDepthEagerDummy -{ - #[Groups(['default'])] - #[ODM\Id(strategy: 'INCREMENT', type: 'int')] - private $id; - #[Groups(['default'])] - #[ODM\Field(name: 'name', type: 'string')] - public $name; - #[Groups(['default'])] - #[MaxDepth(1)] - #[ODM\ReferenceOne(targetDocument: self::class, cascade: ['persist'])] - public $child; - - public function getId() - { - return $this->id; - } -} diff --git a/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php b/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php index e45799155a6..5e2f25793db 100644 --- a/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php +++ b/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php @@ -19,8 +19,9 @@ use ApiPlatform\Metadata\Link; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -#[ApiResource()] +#[ApiResource] #[ApiResource( + shortName: 'RelatedLinkedDummyToFrom', uriTemplate: '/secured_dummies/{securedDummyId}/to_from', operations: [new GetCollection()], uriVariables: [ @@ -28,6 +29,7 @@ ] )] #[ApiResource( + shortName: 'RelatedLinkedDummyWithName', uriTemplate: '/secured_dummies/{securedDummyId}/with_name', operations: [new GetCollection()], uriVariables: [ @@ -35,6 +37,7 @@ ] )] #[ApiResource( + shortName: 'RelatedLinkedDummyMultiLink', uriTemplate: '/secured_dummies/{securedDummyId}/related/{id}', operations: [new GetCollection()], uriVariables: [ diff --git a/tests/Fixtures/TestBundle/Entity/DummyProblem.php b/tests/Fixtures/TestBundle/Entity/DummyProblem.php index a6bf31b5dc9..e7f745f14ab 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyProblem.php +++ b/tests/Fixtures/TestBundle/Entity/DummyProblem.php @@ -22,7 +22,6 @@ /** * DummyProblem. - * Tests features/hal/problem.feature. * * @author Kévin Dunglas */ diff --git a/tests/Fixtures/TestBundle/Entity/JsonldContextDummy.php b/tests/Fixtures/TestBundle/Entity/JsonldContextDummy.php deleted file mode 100644 index bbd39fcea31..00000000000 --- a/tests/Fixtures/TestBundle/Entity/JsonldContextDummy.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; - -use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ORM\Mapping as ORM; - -/** - * Jsonld Context Dummy. - */ -#[ApiResource] -#[ORM\Entity] -class JsonldContextDummy -{ - /** - * @var int The id - */ - #[ApiProperty(identifier: true)] - #[ORM\Column(type: 'integer')] - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - private ?int $id = null; - - /** - * @var string The dummy person - */ - #[ApiProperty( - jsonldContext: ['@id' => 'https://example.com/id', '@type' => '@id', 'foo' => 'bar'] - )] - private $person; - - public function getId(): ?int - { - return $this->id; - } - - public function setPerson($person): void - { - $this->person = $person; - } - - public function getPerson() - { - return $this->person; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/MaxDepthEagerDummy.php b/tests/Fixtures/TestBundle/Entity/MaxDepthEagerDummy.php deleted file mode 100644 index 3a796b31542..00000000000 --- a/tests/Fixtures/TestBundle/Entity/MaxDepthEagerDummy.php +++ /dev/null @@ -1,45 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; - -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Attribute\Groups; -use Symfony\Component\Serializer\Attribute\MaxDepth; - -/** - * @author Brian Fox - */ -#[ApiResource(normalizationContext: ['groups' => ['default'], 'enable_max_depth' => true], denormalizationContext: ['groups' => ['default'], 'enable_max_depth' => true], graphQlOperations: [])] -#[ORM\Entity] -class MaxDepthEagerDummy -{ - #[ORM\Column(type: 'integer')] - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - #[Groups(['default'])] - private $id; - #[ORM\Column(name: 'name', type: 'string', length: 30)] - #[Groups(['default'])] - public $name; - #[ORM\ManyToOne(targetEntity: self::class, cascade: ['persist'])] - #[Groups(['default'])] - #[MaxDepth(1)] - public $child; - - public function getId() - { - return $this->id; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php index 6715a842f79..c02722fce37 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php @@ -19,8 +19,9 @@ use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\Entity; -#[ApiResource()] +#[ApiResource] #[ApiResource( + shortName: 'RelatedLinkedDummyToFrom', uriTemplate: '/secured_dummies/{securedDummyId}/to_from', operations: [new GetCollection()], uriVariables: [ @@ -28,6 +29,7 @@ ] )] #[ApiResource( + shortName: 'RelatedLinkedDummyWithName', uriTemplate: '/secured_dummies/{securedDummyId}/with_name', operations: [new GetCollection()], uriVariables: [ @@ -35,6 +37,7 @@ ] )] #[ApiResource( + shortName: 'RelatedLinkedDummyMultiLink', uriTemplate: '/secured_dummies/{securedDummyId}/related/{id}', operations: [new GetCollection()], uriVariables: [ diff --git a/tests/Fixtures/TestBundle/State/JsonLdPaginationCappedProvider.php b/tests/Fixtures/TestBundle/State/JsonLdPaginationCappedProvider.php new file mode 100644 index 00000000000..2d00adb40e0 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/JsonLdPaginationCappedProvider.php @@ -0,0 +1,39 @@ + + * + * 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\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PaginationCapped; + +/** + * Exercises the framework's Pagination service so cap and validation rules apply. + */ +final class JsonLdPaginationCappedProvider implements ProviderInterface +{ + public function __construct(private readonly Pagination $pagination) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): PaginationCapped => new PaginationCapped($i), range(1, 80)); + + [, $offset, $limit] = $this->pagination->getPagination($operation, $context); + + return new ArrayPaginator($items, $offset, $limit); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 1320c1e2637..77b2d7cbc1a 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -179,6 +179,12 @@ services: tags: - name: 'api_platform.state_provider' + ApiPlatform\Tests\Fixtures\TestBundle\State\JsonLdPaginationCappedProvider: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\JsonLdPaginationCappedProvider' + arguments: ['@api_platform.pagination'] + tags: + - name: 'api_platform.state_provider' + ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor' tags: diff --git a/tests/Functional/Authorization/DenyTest.php b/tests/Functional/Authorization/DenyTest.php new file mode 100644 index 00000000000..838de50670b --- /dev/null +++ b/tests/Functional/Authorization/DenyTest.php @@ -0,0 +1,573 @@ + + * + * 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\Authorization; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6446\SecurityPostValidation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyWithPropertiesDependingOnThemselves; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Security\Core\User\InMemoryUser; + +final class DenyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + SecuredDummy::class, + SecuredDummyWithPropertiesDependingOnThemselves::class, + RelatedLinkedDummy::class, + SecurityPostValidation::class, + ]; + } + + public function testAnonymousGetCollectionReturns401(): void + { + self::createClient()->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(401); + } + + public function testAuthenticatedUserGetCollectionReturns200(): void + { + $this->recreateSchema([SecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testCustomDataProviderGeneratorReturns200(): void + { + $this->recreateSchema([SecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', '/custom_data_provider_generator', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + } + + public function testStandardUserCannotCreate(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('POST', '/secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Title', 'description' => 'Description', 'owner' => 'foo'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testAdminCanCreate(): void + { + $this->recreateSchema([SecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('POST', '/secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Title', 'description' => 'Description', 'owner' => 'someone'], + ]); + $this->assertResponseStatusCodeSame(201); + } + + public function testUserCannotGetItemTheyDontOwn(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testUserCanGetItemTheyOwn(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + } + + public function testOwnerSeesOwnerOnlyAndAttributeBasedProperties(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('ownerOnlyProperty', $body); + $this->assertNotNull($body['ownerOnlyProperty']); + $this->assertArrayHasKey('attributeBasedProperty', $body); + $this->assertNotNull($body['attributeBasedProperty']); + } + + public function testAdminCanCreateWithPropertiesDependingOnThemselves(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SecuredDummyWithPropertiesDependingOnThemselves::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('POST', '/secured_dummy_with_properties_depending_on_themselves', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['canUpdateProperty' => false, 'property' => false], + ]); + $this->assertResponseStatusCodeSame(201); + } + + public function testCannotPatchSecuredPropertyIfNotGranted(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SecuredDummyWithPropertiesDependingOnThemselves::class]); + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin->request('POST', '/secured_dummy_with_properties_depending_on_themselves', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['canUpdateProperty' => false, 'property' => false], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $client->request('PATCH', '/secured_dummy_with_properties_depending_on_themselves/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['canUpdateProperty' => true, 'property' => true], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertTrue($body['canUpdateProperty']); + $this->assertFalse($body['property']); + } + + public function testAdminCannotSeeOwnerOnlyPropertiesOnOthersItems(): void + { + $this->recreateSchema([SecuredDummy::class]); + $admin1 = self::createClient(); + $admin1->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin1->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => 'someone'], + ]); + $this->assertResponseStatusCodeSame(201); + $admin2 = self::createClient(); + $admin2->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin2->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#2', 'owner' => 'dunglas'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringNotContainsString('ownerOnlyProperty', $response->getContent()); + $this->assertStringNotContainsString('attributeBasedProperty', $response->getContent()); + } + + public function testUserCannotReassignItem(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['owner' => 'kitten'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testUserCanTransferItemTheyOwn(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['owner' => 'vincent'], + ]); + $this->assertResponseIsSuccessful(); + } + + public function testAdminSeesAdminOnlyProperty(): void + { + $this->recreateSchema([SecuredDummy::class]); + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => 'dunglas', 'adminOnlyProperty' => 'secret'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('adminOnlyProperty', $response->getContent()); + } + + public function testUserDoesNotSeeAdminOnlyProperty(): void + { + $this->recreateSchema([SecuredDummy::class]); + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => 'someone', 'adminOnlyProperty' => 'secret'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringNotContainsString('adminOnlyProperty', $response->getContent()); + } + + public function testAdminCanCreateWithAdminOnlyProperty(): void + { + $this->recreateSchema([SecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $client->request('POST', '/secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'title' => 'Common Title', + 'description' => 'Description', + 'owner' => 'dunglas', + 'adminOnlyProperty' => 'Is it safe?', + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertStringContainsString('adminOnlyProperty', $response->getContent()); + $this->assertSame('Is it safe?', $body['adminOnlyProperty']); + } + + public function testUserCannotUpdateAdminOnlyProperty(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy( + owner: 'dunglas', + extra: [ + 'title' => 'Common Title', + 'description' => 'Description', + 'adminOnlyProperty' => 'Is it safe?', + ], + ); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['adminOnlyProperty' => 'Yes it is!'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringNotContainsString('adminOnlyProperty', $response->getContent()); + + $adminClient = self::createClient(); + $adminClient->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $listResponse = $adminClient->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $listBody = $listResponse->toArray(); + $this->assertSame('Is it safe?', $listBody['hydra:member'][0]['adminOnlyProperty']); + } + + public function testUserCanUpdateOwnerOnlyAndAttributeBasedProperties(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['ownerOnlyProperty' => 'updated', 'attributeBasedProperty' => 'updated'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('ownerOnlyProperty', $response->getContent()); + $this->assertSame('updated', $body['ownerOnlyProperty']); + $this->assertSame('updated', $body['attributeBasedProperty']); + } + + public function testLinkSecurityNotFoundReturns404(): void + { + $this->recreateSchema([SecuredDummy::class, RelatedLinkedDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', '/secured_dummies/40000/to_from', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(404); + } + + public function testLinkSecurityToFromAuthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', "/secured_dummies/{$securedId}/to_from", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('securedDummy', $response->getContent()); + $this->assertSame($linkedId, $body['hydra:member'][0]['id']); + } + + public function testLinkSecurityWithNameAuthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', "/secured_dummies/{$securedId}/with_name", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('securedDummy', $response->getContent()); + $this->assertSame($linkedId, $body['hydra:member'][0]['id']); + } + + public function testLinkSecurityFromFromAuthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', "/related_linked_dummies/{$linkedId}/from_from", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('id', $response->getContent()); + // The /related_linked_dummies/{relatedDummyId}/from_from operation + // returns the linked SecuredDummy collection, not the relation itself. + $this->assertSame($securedId, $body['hydra:member'][0]['id']); + } + + public function testLinkSecurityMultipleLinksAuthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', "/secured_dummies/{$securedId}/related/{$linkedId}", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('id', $response->getContent()); + $this->assertSame($linkedId, $body['hydra:member'][0]['id']); + } + + public function testLinkSecurityToFromUnauthorized(): void + { + [$securedId] = $this->seedLinkedDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', "/secured_dummies/{$securedId}/to_from", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testLinkSecurityWithNameUnauthorized(): void + { + [$securedId] = $this->seedLinkedDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', "/secured_dummies/{$securedId}/with_name", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testLinkSecurityFromFromUnauthorized(): void + { + [, $linkedId] = $this->seedLinkedDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', "/related_linked_dummies/{$linkedId}/from_from", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testLinkSecurityMultipleLinksUnauthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', "/secured_dummies/{$securedId}/related/{$linkedId}", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + /** + * Admin-POSTs a SecuredDummy and returns its IRI. Avoids hard-coding id=1, + * which is flaky on MongoDB ODM (INCREMENT counter survives collection drops). + */ + private function createSecuredDummy(string $owner, array $extra = []): string + { + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => $owner] + $extra, + ]); + $this->assertResponseStatusCodeSame(201); + + return $response->toArray()['@id']; + } + + /** + * Seeds one SecuredDummy + one RelatedLinkedDummy via the API so the same + * helper works against either ORM or ODM persistence, and returns the + * generated ids (parsed from the IRIs). Hard-coding id=1 is flaky. + * + * @return array{0:int, 1:int} + */ + private function seedLinkedDummy(string $owner): array + { + $this->recreateSchema([SecuredDummy::class, RelatedLinkedDummy::class]); + + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $dummyResponse = $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => $owner], + ]); + $this->assertResponseStatusCodeSame(201); + $securedIri = $dummyResponse->toArray()['@id']; + + $linkedResponse = $admin->request('POST', '/related_linked_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['securedDummy' => $securedIri], + ]); + $this->assertResponseStatusCodeSame(201); + $linkedId = $linkedResponse->toArray()['id']; + + $securedId = (int) basename($securedIri); + + return [$securedId, (int) $linkedId]; + } + + public function testUserSeesOwnerOnlyPropertyWithJsonFormat(): void + { + $this->recreateSchema([SecuredDummy::class]); + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => 'dunglas'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('ownerOnlyProperty', $response->getContent()); + $this->assertStringContainsString('attributeBasedProperty', $response->getContent()); + } + + public function testSecurityPostValidation(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('POST', '/issue_6446', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => ''], + ]); + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Functional/Authorization/LegacyDenyTest.php b/tests/Functional/Authorization/LegacyDenyTest.php new file mode 100644 index 00000000000..d93149724a5 --- /dev/null +++ b/tests/Functional/Authorization/LegacyDenyTest.php @@ -0,0 +1,159 @@ + + * + * 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\Authorization; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LegacySecuredDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Security\Core\User\InMemoryUser; + +final class LegacyDenyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [LegacySecuredDummy::class]; + } + + public function testAnonymousGetCollectionReturns401(): void + { + self::createClient()->request('GET', '/legacy_secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(401); + } + + public function testAuthenticatedUserGetCollectionReturns200(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', '/legacy_secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testStandardUserCannotCreate(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('POST', '/legacy_secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Title', 'description' => 'Description', 'owner' => 'foo'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testAdminCanCreate(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('POST', '/legacy_secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Title', 'description' => 'Description', 'owner' => 'someone'], + ]); + $this->assertResponseStatusCodeSame(201); + } + + public function testUserCannotGetItemTheyDontOwn(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $iri = $this->createLegacySecuredDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testUserCanGetItemTheyOwn(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $iri = $this->createLegacySecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + } + + public function testUserCannotReassignItem(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $iri = $this->createLegacySecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['owner' => 'kitten'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testUserCanTransferItemTheyOwn(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $iri = $this->createLegacySecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['owner' => 'vincent'], + ]); + $this->assertResponseIsSuccessful(); + } + + /** + * Avoids hard-coding id=1, which is flaky on MongoDB ODM (INCREMENT counter + * survives collection drops). + */ + private function createLegacySecuredDummy(string $owner): string + { + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $admin->request('POST', '/legacy_secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'description' => '', 'owner' => $owner], + ]); + $this->assertResponseStatusCodeSame(201); + + return $response->toArray()['@id']; + } +} diff --git a/tests/Functional/Hal/AbsoluteUrlTest.php b/tests/Functional/Hal/AbsoluteUrlTest.php new file mode 100644 index 00000000000..ee61a90757c --- /dev/null +++ b/tests/Functional/Hal/AbsoluteUrlTest.php @@ -0,0 +1,85 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\AbsoluteUrlChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\AbsoluteUrlParent; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AbsoluteUrlTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [AbsoluteUrlChild::class, AbsoluteUrlParent::class]; + } + + public function testCollectionLinksUseAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_absolute_url_children', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/hal_absolute_url_children', $body['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_children/1', $body['_links']['item'][0]['href']); + $this->assertSame('http://example.com/hal_absolute_url_children/1', $body['_embedded']['item'][0]['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_parents/1', $body['_embedded']['item'][0]['_links']['parent']['href']); + } + + public function testItemLinksUseAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_absolute_url_children/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/hal_absolute_url_children/1', $body['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_parents/1', $body['_links']['parent']['href']); + } + + public function testPostAcceptsAbsoluteUrlInPayload(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/hal_absolute_url_children', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['parent' => 'http://example.com/hal_absolute_url_parents/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('http://example.com/hal_absolute_url_children/2', $body['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_parents/1', $body['_links']['parent']['href']); + } + + public function testSubresourceCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_absolute_url_parents/1/children', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/hal_absolute_url_parents/1/children', $body['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_children/1', $body['_links']['item'][0]['href']); + } +} diff --git a/tests/Functional/Hal/CollectionTest.php b/tests/Functional/Hal/CollectionTest.php new file mode 100644 index 00000000000..fa338eb8f08 --- /dev/null +++ b/tests/Functional/Hal/CollectionTest.php @@ -0,0 +1,167 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\CollectionPagedResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CollectionPagedResource::class]; + } + + public function testFirstPageHasFirstThreeItemsAndNextLink(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + + $this->assertSame('/hal_collection_paged?page=1', $body['_links']['self']['href']); + $this->assertSame('/hal_collection_paged?page=1', $body['_links']['first']['href']); + $this->assertSame('/hal_collection_paged?page=4', $body['_links']['last']['href']); + $this->assertSame('/hal_collection_paged?page=2', $body['_links']['next']['href']); + $this->assertCount(3, $body['_links']['item']); + $this->assertSame(10, $body['totalItems']); + $this->assertSame(3, $body['itemsPerPage']); + $this->assertSame([1, 2, 3], array_column($body['_embedded']['item'], 'id')); + } + + public function testMiddlePageHasPrevAndNext(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?page=3', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?page=3', $body['_links']['self']['href']); + $this->assertSame('/hal_collection_paged?page=2', $body['_links']['prev']['href']); + $this->assertSame('/hal_collection_paged?page=4', $body['_links']['next']['href']); + $this->assertSame([7, 8, 9], array_column($body['_embedded']['item'], 'id')); + } + + public function testLastPageOmitsNext(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?page=4', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?page=4', $body['_links']['self']['href']); + $this->assertSame('/hal_collection_paged?page=3', $body['_links']['prev']['href']); + $this->assertArrayNotHasKey('next', $body['_links']); + $this->assertSame([10], array_column($body['_embedded']['item'], 'id')); + } + + public function testPartialPaginationDropsFirstAndLast(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?page=2&partial=1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayNotHasKey('first', $body['_links']); + $this->assertArrayNotHasKey('last', $body['_links']); + $this->assertArrayHasKey('prev', $body['_links']); + $this->assertArrayHasKey('next', $body['_links']); + $this->assertArrayNotHasKey('totalItems', $body); + $this->assertSame(3, $body['itemsPerPage']); + $this->assertSame([4, 5, 6], array_column($body['_embedded']['item'], 'id')); + } + + public function testPaginationDisabledExposesAllItems(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?pagination=0', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?pagination=0', $body['_links']['self']['href']); + $this->assertCount(10, $body['_links']['item']); + $this->assertCount(10, $body['_embedded']['item']); + $this->assertSame(10, $body['totalItems']); + } + + public function testItemsPerPageOverridesDefault(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?page=2&itemsPerPage=1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?itemsPerPage=1&page=2', $body['_links']['self']['href']); + $this->assertSame('/hal_collection_paged?itemsPerPage=1&page=1', $body['_links']['first']['href']); + $this->assertSame('/hal_collection_paged?itemsPerPage=1&page=10', $body['_links']['last']['href']); + $this->assertSame(1, $body['itemsPerPage']); + $this->assertSame([2], array_column($body['_embedded']['item'], 'id')); + } + + public function testFilterByEncodedIriPreservedInLinks(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?id=%2fdummies%2f8', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?id=%2Fdummies%2F8', $body['_links']['self']['href']); + $this->assertSame(1, $body['totalItems']); + $this->assertSame([8], array_column($body['_embedded']['item'], 'id')); + } + + public function testFilterByEncodedNamePreservedInLinks(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?name=Dummy%20%238', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?name=Dummy%20%238', $body['_links']['self']['href']); + $this->assertSame(1, $body['totalItems']); + $this->assertSame([8], array_column($body['_embedded']['item'], 'id')); + } + + public function testItemsPerPageZeroReturnsEmptyEmbeddedItems(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?itemsPerPage=0', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?itemsPerPage=0', $body['_links']['self']['href']); + $this->assertSame(10, $body['totalItems']); + $this->assertSame(0, $body['itemsPerPage']); + $this->assertArrayNotHasKey('item', $body['_links']); + } + + public function testEmptyCollectionExposesNoItems(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?id=999', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame(0, $body['totalItems']); + $this->assertSame(3, $body['itemsPerPage']); + $this->assertArrayNotHasKey('item', $body['_links']); + } +} diff --git a/tests/Functional/Hal/HalTest.php b/tests/Functional/Hal/HalTest.php new file mode 100644 index 00000000000..bf1fbc77072 --- /dev/null +++ b/tests/Functional/Hal/HalTest.php @@ -0,0 +1,120 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\HalRelatedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\HalThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\RelationEmbedder; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HalTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationEmbedder::class, HalRelatedResource::class, HalThirdLevel::class]; + } + + public function testEntrypointListsResourcesAsHalLinks(): void + { + $response = self::createClient()->request('GET', '/', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/', $body['_links']['self']['href']); + $hrefs = array_column($body['_links'], 'href'); + $this->assertContains('/hal_relation_embedders', $hrefs); + } + + public function testGetEmbedsRelatedResourceAndItsRelation(): void + { + $response = self::createClient()->request('GET', '/hal_relation_embedders/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + + $this->assertSame('/hal_relation_embedders/1', $body['_links']['self']['href']); + $this->assertSame('/hal_related_resources/1', $body['_links']['related']['href']); + $this->assertSame('Krondstadt', $body['krondstadt']); + + $related = $body['_embedded']['related']; + $this->assertSame('/hal_related_resources/1', $related['_links']['self']['href']); + $this->assertSame('/hal_third_levels/1', $related['_links']['thirdLevel']['href']); + $this->assertSame('symfony', $related['symfony']); + + $thirdLevel = $related['_embedded']['thirdLevel']; + $this->assertSame('/hal_third_levels/1', $thirdLevel['_links']['self']['href']); + $this->assertSame(3, $thirdLevel['level']); + } + + public function testPostAcceptsIriRelationAndReturnsHalPayload(): void + { + $response = self::createClient()->request('POST', '/hal_relation_embedders', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['related' => '/hal_related_resources/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/hal_relation_embedders/1', $body['_links']['self']['href']); + $this->assertSame('/hal_related_resources/1', $body['_links']['related']['href']); + $this->assertSame('Krondstadt', $body['krondstadt']); + } + + public function testPutReturnsHalPayloadAndKeepsPreviousRelation(): void + { + $response = self::createClient()->request('PUT', '/hal_relation_embedders/1', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['krondstadt' => 'Updated'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/hal_relation_embedders/1', $body['_links']['self']['href']); + $this->assertSame('/hal_related_resources/1', $body['_links']['related']['href']); + $this->assertSame('Updated', $body['krondstadt']); + $this->assertSame('/hal_related_resources/1', $body['_embedded']['related']['_links']['self']['href']); + } + + public function testPatchReturnsHalPayloadWithMergePatch(): void + { + $response = self::createClient()->request('PATCH', '/hal_relation_embedders/1', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/merge-patch+json', + ], + 'json' => ['krondstadt' => 'Patched'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/hal_relation_embedders/1', $body['_links']['self']['href']); + $this->assertSame('/hal_related_resources/1', $body['_links']['related']['href']); + $this->assertSame('Patched', $body['krondstadt']); + $this->assertSame('/hal_related_resources/1', $body['_embedded']['related']['_links']['self']['href']); + } +} diff --git a/tests/Functional/Hal/InputOutputDtoTest.php b/tests/Functional/Hal/InputOutputDtoTest.php new file mode 100644 index 00000000000..6ae2f29bb1f --- /dev/null +++ b/tests/Functional/Hal/InputOutputDtoTest.php @@ -0,0 +1,56 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\CustomOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputDtoTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CustomOutputResource::class]; + } + + public function testItemReturnsCustomOutput(): void + { + $response = self::createClient()->request('GET', '/hal_custom_outputs/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('test', $body['foo']); + $this->assertSame(1, $body['bar']); + } + + public function testCollectionEmbedsCustomOutputItems(): void + { + $response = self::createClient()->request('GET', '/hal_custom_outputs', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(2, $body['_embedded']['item']); + $this->assertSame('test', $body['_embedded']['item'][0]['foo']); + $this->assertSame(1, $body['_embedded']['item'][0]['bar']); + $this->assertSame('test', $body['_embedded']['item'][1]['foo']); + $this->assertSame(2, $body['_embedded']['item'][1]['bar']); + } +} diff --git a/tests/Functional/Hal/ItemUriTemplateTest.php b/tests/Functional/Hal/ItemUriTemplateTest.php new file mode 100644 index 00000000000..2196881211c --- /dev/null +++ b/tests/Functional/Hal/ItemUriTemplateTest.php @@ -0,0 +1,69 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\UriTemplateCar; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ItemUriTemplateTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [UriTemplateCar::class]; + } + + public function testCollectionWithoutItemUriTemplateUsesFirstGetOperation(): void + { + $response = self::createClient()->request('GET', '/hal_uri_template_cars', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + + $this->assertSame('/hal_uri_template_cars', $body['_links']['self']['href']); + $this->assertCount(2, $body['_links']['item']); + foreach ($body['_links']['item'] as $link) { + $this->assertMatchesRegularExpression('#^/hal_uri_template_cars/.+$#', $link['href']); + } + $this->assertCount(2, $body['_embedded']['item']); + foreach ($body['_embedded']['item'] as $item) { + $this->assertMatchesRegularExpression('#^/hal_uri_template_cars/.+$#', $item['_links']['self']['href']); + $this->assertSame('Vincent', $item['owner']); + } + } + + public function testCollectionWithItemUriTemplateGeneratesIriFromTargetOperation(): void + { + $response = self::createClient()->request('GET', '/hal_uri_template_brands/renault/cars', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + + $this->assertSame('/hal_uri_template_brands/renault/cars', $body['_links']['self']['href']); + $this->assertCount(2, $body['_links']['item']); + foreach ($body['_links']['item'] as $link) { + $this->assertMatchesRegularExpression('#^/hal_uri_template_brands/renault/cars/.+$#', $link['href']); + } + $this->assertCount(2, $body['_embedded']['item']); + foreach ($body['_embedded']['item'] as $item) { + $this->assertMatchesRegularExpression('#^/hal_uri_template_brands/renault/cars/.+$#', $item['_links']['self']['href']); + } + } +} diff --git a/tests/Functional/Hal/MaxDepthTest.php b/tests/Functional/Hal/MaxDepthTest.php new file mode 100644 index 00000000000..007aa270b34 --- /dev/null +++ b/tests/Functional/Hal/MaxDepthTest.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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\MaxDepthResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MaxDepthTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [MaxDepthResource::class]; + } + + public function testFirstLevelChildIsEmbedded(): void + { + $response = self::createClient()->request('POST', '/hal_max_depth_resources', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'name' => 'level 1', + 'child' => ['name' => 'level 2'], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertArrayHasKey('_embedded', $body); + $this->assertArrayHasKey('child', $body['_embedded']); + $this->assertSame('level 2', $body['_embedded']['child']['name']); + $this->assertArrayNotHasKey('_embedded', $body['_embedded']['child']); + } + + public function testSecondLevelChildIsTruncatedByMaxDepth(): void + { + $response = self::createClient()->request('POST', '/hal_max_depth_resources', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'name' => 'level 1', + 'child' => [ + 'name' => 'level 2', + 'child' => ['name' => 'level 3'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertArrayHasKey('_embedded', $body); + $this->assertArrayHasKey('child', $body['_embedded']); + $this->assertSame('level 2', $body['_embedded']['child']['name']); + $this->assertArrayNotHasKey('_embedded', $body['_embedded']['child']); + } + + public function testPutTruncatesSecondLevelChildByMaxDepth(): void + { + $response = self::createClient()->request('PUT', '/hal_max_depth_resources/1', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'child' => [ + 'child' => ['name' => 'level 3'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertArrayHasKey('_embedded', $body); + $this->assertArrayHasKey('child', $body['_embedded']); + $this->assertArrayNotHasKey('_embedded', $body['_embedded']['child']); + } +} diff --git a/tests/Functional/Hal/NetworkPathTest.php b/tests/Functional/Hal/NetworkPathTest.php new file mode 100644 index 00000000000..d3dafe1bbd8 --- /dev/null +++ b/tests/Functional/Hal/NetworkPathTest.php @@ -0,0 +1,84 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\NetworkPathParent; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\NetworkPathResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NetworkPathTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NetworkPathResource::class, NetworkPathParent::class]; + } + + public function testCollectionLinksUseNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_network_path_children', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/hal_network_path_children', $body['_links']['self']['href']); + $this->assertSame('//example.com/hal_network_path_children/1', $body['_links']['item'][0]['href']); + $this->assertSame('//example.com/hal_network_path_parents/1', $body['_embedded']['item'][0]['_links']['parent']['href']); + } + + public function testItemLinksUseNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_network_path_children/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/hal_network_path_children/1', $body['_links']['self']['href']); + $this->assertSame('//example.com/hal_network_path_parents/1', $body['_links']['parent']['href']); + } + + public function testPostAcceptsNetworkPathInPayload(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/hal_network_path_children', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['parent' => '//example.com/hal_network_path_parents/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('//example.com/hal_network_path_children/2', $body['_links']['self']['href']); + $this->assertSame('//example.com/hal_network_path_parents/1', $body['_links']['parent']['href']); + } + + public function testSubresourceCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_network_path_parents/1/children', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/hal_network_path_parents/1/children', $body['_links']['self']['href']); + $this->assertSame('//example.com/hal_network_path_children/1', $body['_links']['item'][0]['href']); + } +} diff --git a/tests/Functional/Hal/NonResourceTest.php b/tests/Functional/Hal/NonResourceTest.php new file mode 100644 index 00000000000..8292a16ec69 --- /dev/null +++ b/tests/Functional/Hal/NonResourceTest.php @@ -0,0 +1,50 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\NonResourceContainer; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NonResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NonResourceContainer::class]; + } + + public function testNestedResourceIsEmbeddedAndRawObjectIsInlined(): void + { + $response = self::createClient()->request('GET', '/hal_non_resource_containers/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + + $this->assertSame('/hal_non_resource_containers/1', $body['_links']['self']['href']); + $this->assertSame('/hal_non_resource_containers/1-nested', $body['_links']['nested']['href']); + $this->assertSame('1', $body['id']); + $this->assertSame(['foo' => 'f1', 'bar' => 'b1'], $body['notAResource']); + + $nested = $body['_embedded']['nested']; + $this->assertSame('/hal_non_resource_containers/1-nested', $nested['_links']['self']['href']); + $this->assertSame('1-nested', $nested['id']); + $this->assertSame(['foo' => 'f2', 'bar' => 'b2'], $nested['notAResource']); + } +} diff --git a/tests/Functional/Hal/ProblemTest.php b/tests/Functional/Hal/ProblemTest.php new file mode 100644 index 00000000000..4c5e5136763 --- /dev/null +++ b/tests/Functional/Hal/ProblemTest.php @@ -0,0 +1,77 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\ProblemRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\ProblemResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ProblemTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ProblemResource::class, ProblemRelation::class]; + } + + public function testValidationErrorIsReturnedAsProblemJson(): void + { + $response = self::createClient()->request('POST', '/hal_problems', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', $body['type']); + $this->assertSame('An error occurred', $body['title']); + $this->assertSame('name: This value should not be blank.', $body['detail']); + $this->assertSame(422, $body['status']); + $this->assertSame([ + [ + 'propertyPath' => 'name', + 'message' => 'This value should not be blank.', + 'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ], + ], $body['violations']); + } + + public function testNestedRelationDocumentReturns400Problem(): void + { + $response = self::createClient()->request('POST', '/hal_problems', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'name' => 'Foo', + 'relatedDummy' => ['name' => 'bar'], + ], + ]); + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('/errors/400', $body['type']); + $this->assertSame('An error occurred', $body['title']); + $this->assertSame('Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.', $body['detail']); + $this->assertArrayHasKey('trace', $body); + } +} diff --git a/tests/Functional/Hal/PropertyCollectionIriOnlyTest.php b/tests/Functional/Hal/PropertyCollectionIriOnlyTest.php new file mode 100644 index 00000000000..dd92836af17 --- /dev/null +++ b/tests/Functional/Hal/PropertyCollectionIriOnlyTest.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelationSecondLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PropertyCollectionIriOnlyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]; + } + + public function testPropertyUriTemplatesRenderAsLinks(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]); + + $manager = $this->getManager(); + $rel1 = new PropertyCollectionIriOnlyRelation(); + $rel1->name = 'asb1'; + $rel2 = new PropertyCollectionIriOnlyRelation(); + $rel2->name = 'asb2'; + $toOne = new PropertyUriTemplateOneToOneRelation(); + $toOne->name = 'xarguš'; + $parent = new PropertyCollectionIriOnly(); + $parent->addPropertyCollectionIriOnlyRelation($rel1); + $parent->addPropertyCollectionIriOnlyRelation($rel2); + $parent->setToOneRelation($toOne); + $manager->persist($parent); + $manager->persist($rel1); + $manager->persist($rel2); + $manager->persist($toOne); + $manager->flush(); + + $response = self::createClient()->request('GET', '/property_collection_iri_onlies/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + + $this->assertSame('/property_collection_iri_onlies/1', $body['_links']['self']['href']); + $this->assertSame('/property-collection-relations', $body['_links']['propertyCollectionIriOnlyRelation']['href']); + $this->assertSame('/parent/1/another-collection-operations', $body['_links']['iterableIri']['href']); + $this->assertSame('/parent/1/property-uri-template/one-to-ones/1', $body['_links']['toOneRelation']['href']); + + $embedded = $body['_embedded']; + $this->assertCount(2, $embedded['propertyCollectionIriOnlyRelation']); + $this->assertSame('asb1', $embedded['propertyCollectionIriOnlyRelation'][0]['name']); + $this->assertSame('asb2', $embedded['propertyCollectionIriOnlyRelation'][1]['name']); + $this->assertSame('xarguš', $embedded['toOneRelation']['name']); + $this->assertSame('/parent/1/property-uri-template/one-to-ones/1', $embedded['toOneRelation']['_links']['self']['href']); + } +} diff --git a/tests/Functional/Hal/TableInheritanceTest.php b/tests/Functional/Hal/TableInheritanceTest.php new file mode 100644 index 00000000000..d5c1f89e86e --- /dev/null +++ b/tests/Functional/Hal/TableInheritanceTest.php @@ -0,0 +1,165 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceDifferentChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceRelated; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class TableInheritanceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceRelated::class, + DummyTableInheritanceNotApiResourceChild::class, + ]; + } + + public function testCreateChildExposesParentAndChildFields(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceRelated::class, + DummyTableInheritanceNotApiResourceChild::class, + ]); + + $response = self::createClient()->request('POST', '/dummy_table_inheritance_children', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['name' => 'foo', 'nickname' => 'bar'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame([ + '_links' => ['self' => ['href' => '/dummy_table_inheritance_children/1']], + 'nickname' => 'bar', + 'id' => 1, + 'name' => 'foo', + ], $body); + } + + public function testParentCollectionMixesChildAndParent(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceRelated::class, + DummyTableInheritanceNotApiResourceChild::class, + ]); + + $manager = $this->getManager(); + $child = new DummyTableInheritanceChild(); + $child->setName('foo'); + $child->setNickname('bar'); + $manager->persist($child); + $parent = new DummyTableInheritance(); + $parent->setName('Foobarbaz inheritance'); + $manager->persist($parent); + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_table_inheritances', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + + $this->assertSame('/dummy_table_inheritances', $body['_links']['self']['href']); + $this->assertSame(2, $body['totalItems']); + $this->assertSame('/dummy_table_inheritance_children/1', $body['_links']['item'][0]['href']); + $this->assertSame('/dummy_table_inheritances/2', $body['_links']['item'][1]['href']); + + $this->assertSame('bar', $body['_embedded']['item'][0]['nickname']); + $this->assertSame('foo', $body['_embedded']['item'][0]['name']); + $this->assertArrayNotHasKey('nickname', $body['_embedded']['item'][1]); + $this->assertSame('Foobarbaz inheritance', $body['_embedded']['item'][1]['name']); + } + + public function testRelatedEntityWithMixedChildren(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceRelated::class, + DummyTableInheritanceNotApiResourceChild::class, + ]); + + $manager = $this->getManager(); + $child = new DummyTableInheritanceChild(); + $child->setName('foo'); + $child->setNickname('bar'); + $manager->persist($child); + $parent = new DummyTableInheritance(); + $parent->setName('Foobarbaz inheritance'); + $manager->persist($parent); + $manager->flush(); + + $response = self::createClient()->request('POST', '/dummy_table_inheritance_relateds', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'children' => [ + '/dummy_table_inheritance_children/1', + '/dummy_table_inheritances/2', + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/dummy_table_inheritance_relateds/1', $body['_links']['self']['href']); + $this->assertSame('/dummy_table_inheritance_children/1', $body['_links']['children'][0]['href']); + $this->assertSame('/dummy_table_inheritances/2', $body['_links']['children'][1]['href']); + + $children = $body['_embedded']['children']; + $this->assertSame('/dummy_table_inheritance_children/1', $children[0]['_links']['self']['href']); + $this->assertSame('bar', $children[0]['nickname']); + $this->assertSame('foo', $children[0]['name']); + $this->assertSame('/dummy_table_inheritances/2', $children[1]['_links']['self']['href']); + $this->assertSame('Foobarbaz inheritance', $children[1]['name']); + $this->assertArrayNotHasKey('nickname', $children[1]); + } +} diff --git a/tests/Functional/JsonApi/AbsoluteUrlTest.php b/tests/Functional/JsonApi/AbsoluteUrlTest.php new file mode 100644 index 00000000000..1566f03534c --- /dev/null +++ b/tests/Functional/JsonApi/AbsoluteUrlTest.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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\AbsoluteUrlDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\AbsoluteUrlRelationDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AbsoluteUrlTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [AbsoluteUrlDummy::class, AbsoluteUrlRelationDummy::class]; + } + + public function testCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_absolute_url_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('http://example.com/jsonapi_absolute_url_dummies', $body['links']['self']); + $this->assertSame('http://example.com/jsonapi_absolute_url_dummies/1', $body['data'][0]['id']); + $this->assertSame('JsonApiAbsoluteUrlDummy', $body['data'][0]['type']); + $this->assertSame( + 'http://example.com/jsonapi_absolute_url_relation_dummies/1', + $body['data'][0]['relationships']['absoluteUrlRelationDummy']['data']['id'], + ); + } + + public function testItemUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_absolute_url_dummies/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/jsonapi_absolute_url_dummies/1', $body['data']['id']); + $this->assertSame('JsonApiAbsoluteUrlDummy', $body['data']['type']); + $this->assertSame( + 'http://example.com/jsonapi_absolute_url_relation_dummies/1', + $body['data']['relationships']['absoluteUrlRelationDummy']['data']['id'], + ); + } + + public function testPostReturnsAbsoluteUrl(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonapi_absolute_url_relation_dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => ['data' => ['type' => 'JsonApiAbsoluteUrlRelationDummy']], + ]); + + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('http://example.com/jsonapi_absolute_url_relation_dummies/2', $body['data']['id']); + $this->assertSame('JsonApiAbsoluteUrlRelationDummy', $body['data']['type']); + } + + public function testSubresourceCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_absolute_url_relation_dummies/1/absolute_url_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame( + 'http://example.com/jsonapi_absolute_url_relation_dummies/1/absolute_url_dummies', + $body['links']['self'], + ); + $this->assertSame('http://example.com/jsonapi_absolute_url_dummies/1', $body['data'][0]['id']); + } +} diff --git a/tests/Functional/JsonApi/CollectionAttributesTest.php b/tests/Functional/JsonApi/CollectionAttributesTest.php new file mode 100644 index 00000000000..d19b84fa882 --- /dev/null +++ b/tests/Functional/JsonApi/CollectionAttributesTest.php @@ -0,0 +1,47 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\CircularReference; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionAttributesTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CircularReference::class]; + } + + public function testCollectionAttributeSerializesAsRelationshipArray(): void + { + $response = self::createClient()->request('GET', '/jsonapi_circular_references/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/jsonapi_circular_references/1', $body['data']['id']); + $this->assertSame('/jsonapi_circular_references/1', $body['data']['relationships']['parent']['data']['id']); + $this->assertCount(2, $body['data']['relationships']['children']['data']); + foreach ($body['data']['relationships']['children']['data'] as $child) { + $this->assertMatchesRegularExpression('#^/jsonapi_circular_references/(1|2)$#', $child['id']); + } + } +} diff --git a/tests/Functional/JsonApi/CollectionUriTemplateTest.php b/tests/Functional/JsonApi/CollectionUriTemplateTest.php new file mode 100644 index 00000000000..8541a7de3a7 --- /dev/null +++ b/tests/Functional/JsonApi/CollectionUriTemplateTest.php @@ -0,0 +1,108 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelationSecondLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionUriTemplateTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]; + } + + public function testPropertyUriTemplatesRenderInJsonApi(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]); + + $manager = $this->getManager(); + $rel1 = new PropertyCollectionIriOnlyRelation(); + $rel1->name = 'asb1'; + $rel2 = new PropertyCollectionIriOnlyRelation(); + $rel2->name = 'asb2'; + $toOne = new PropertyUriTemplateOneToOneRelation(); + $toOne->name = 'xarguš'; + $parent = new PropertyCollectionIriOnly(); + $parent->addPropertyCollectionIriOnlyRelation($rel1); + $parent->addPropertyCollectionIriOnlyRelation($rel2); + $parent->setToOneRelation($toOne); + $manager->persist($parent); + $manager->persist($rel1); + $manager->persist($rel2); + $manager->persist($toOne); + $manager->flush(); + + $response = self::createClient()->request('GET', '/property_collection_iri_onlies/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'links' => [ + 'propertyCollectionIriOnlyRelation' => '/property-collection-relations', + 'iterableIri' => '/parent/1/another-collection-operations', + 'toOneRelation' => '/parent/1/property-uri-template/one-to-ones/1', + ], + 'data' => [ + 'id' => '/property_collection_iri_onlies/1', + 'type' => 'PropertyCollectionIriOnly', + 'relationships' => [ + 'propertyCollectionIriOnlyRelation' => [ + 'data' => [ + ['type' => 'PropertyCollectionIriOnlyRelation', 'id' => '/property_collection_iri_only_relations/1'], + ['type' => 'PropertyCollectionIriOnlyRelation', 'id' => '/property_collection_iri_only_relations/2'], + ], + ], + 'iterableIri' => [ + 'data' => [ + ['type' => 'PropertyCollectionIriOnlyRelation', 'id' => '/property_collection_iri_only_relations/9999'], + ], + ], + 'toOneRelation' => [ + 'data' => [ + 'type' => 'PropertyUriTemplateOneToOneRelation', + 'id' => '/parent/1/property-uri-template/one-to-ones/1', + ], + ], + ], + ], + ]); + } +} diff --git a/tests/Functional/JsonApi/CrudTest.php b/tests/Functional/JsonApi/CrudTest.php new file mode 100644 index 00000000000..fb759b9b4af --- /dev/null +++ b/tests/Functional/JsonApi/CrudTest.php @@ -0,0 +1,362 @@ + + * + * 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\JsonApi; + +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; + +final class CrudTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + ThirdLevel::class, + RelatedDummy::class, + Dummy::class, + RelationEmbedder::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema(self::getResources()); + } + + public function testCreateThirdLevel(): void + { + $response = self::createClient()->request('POST', '/third_levels', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'third-level', + 'attributes' => ['level' => 3], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/third_levels/1', $body['data']['id']); + $this->assertSame('ThirdLevel', $body['data']['type']); + } + + public function testGetThirdLevelCollection(): void + { + $this->seedThirdLevel(); + + $response = self::createClient()->request('GET', '/third_levels', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertCount(1, $body['data']); + } + + public function testGetThirdLevelItem(): void + { + $this->seedThirdLevel(); + + $response = self::createClient()->request('GET', '/third_levels/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/third_levels/1', $body['data']['id']); + } + + public function testCreateRelatedDummyWithThirdLevelRelation(): void + { + $this->seedThirdLevel(); + + $response = self::createClient()->request('POST', '/related_dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'related-dummy', + 'attributes' => ['name' => 'John Doe', 'age' => 23], + 'relationships' => [ + 'thirdLevel' => [ + 'data' => ['type' => 'third-level', 'id' => '/third_levels/1'], + ], + ], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/related_dummies/1', $body['data']['id']); + $this->assertSame('John Doe', $body['data']['attributes']['name']); + $this->assertSame(23, $body['data']['attributes']['age']); + } + + public function testCreateRelatedDummyWithEmptyThirdLevel(): void + { + $response = self::createClient()->request('POST', '/related_dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'related-dummy', + 'attributes' => ['name' => 'John Doe'], + 'relationships' => [ + 'thirdLevel' => ['data' => null], + ], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + } + + public function testCreateDummyWithRelations(): void + { + $this->seedRelatedDummies(2); + + $response = self::createClient()->request('POST', '/dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'dummy', + 'attributes' => [ + 'name' => 'Dummy with relations', + 'dummyDate' => '2015-03-01T10:00:00+00:00', + ], + 'relationships' => [ + 'relatedDummy' => [ + 'data' => ['type' => 'related-dummy', 'id' => '/related_dummies/2'], + ], + 'relatedDummies' => [ + 'data' => [ + ['type' => 'related-dummy', 'id' => '/related_dummies/1'], + ['type' => 'related-dummy', 'id' => '/related_dummies/2'], + ], + ], + ], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertCount(2, $body['data']['relationships']['relatedDummies']['data']); + $this->assertSame( + '/related_dummies/2', + $body['data']['relationships']['relatedDummy']['data']['id'], + ); + } + + public function testPatchDummyManyToMany(): void + { + $this->seedRelatedDummies(2); + $this->seedDummyWithTwoRelatedDummies(); + + $response = self::createClient()->request('PATCH', '/dummies/1', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'dummy', + 'relationships' => [ + 'relatedDummy' => [ + 'data' => ['type' => 'related-dummy', 'id' => '/related_dummies/1'], + ], + 'relatedDummies' => [ + 'data' => [ + ['type' => 'related-dummy', 'id' => '/related_dummies/2'], + ], + ], + ], + ], + ], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(1, $body['data']['relationships']['relatedDummies']['data']); + $this->assertSame( + '/related_dummies/1', + $body['data']['relationships']['relatedDummy']['data']['id'], + ); + } + + public function testGetCollectionRelatedDummiesExposesRelationships(): void + { + $this->seedRelatedDummiesWithThirdLevel(1); + + $response = self::createClient()->request('GET', '/related_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame( + '/third_levels/1', + $body['data'][0]['relationships']['thirdLevel']['data']['id'], + ); + } + + public function testGetRelatedDummyFullBody(): void + { + $this->seedRelatedDummiesWithThirdLevel(1); + + $response = self::createClient()->request('GET', '/related_dummies/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/related_dummies/1', + 'type' => 'RelatedDummy', + 'attributes' => [ + '_id' => 1, + 'name' => 'John Doe', + 'symfony' => 'symfony', + 'age' => 23, + ], + 'relationships' => [ + 'thirdLevel' => [ + 'data' => ['type' => 'ThirdLevel', 'id' => '/third_levels/1'], + ], + ], + ], + ]); + } + + public function testPatchRelatedDummyName(): void + { + $this->seedRelatedDummiesWithThirdLevel(1); + + $response = self::createClient()->request('PATCH', '/related_dummies/1', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'related-dummy', + 'attributes' => ['name' => 'Jane Doe'], + ], + ], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('Jane Doe', $body['data']['attributes']['name']); + $this->assertSame(23, $body['data']['attributes']['age']); + } + + public function testCreateRelationEmbedder(): void + { + $this->seedRelatedDummies(1); + + $response = self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'relationships' => [ + 'related' => [ + 'data' => ['type' => 'related-dummy', 'id' => '/related_dummies/1'], + ], + ], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('Krondstadt', $body['data']['attributes']['krondstadt']); + $this->assertSame( + '/related_dummies/1', + $body['data']['relationships']['related']['data']['id'], + ); + } + + private function seedThirdLevel(): void + { + $manager = $this->getManager(); + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $manager->persist($thirdLevel); + $manager->flush(); + $manager->clear(); + } + + private function seedRelatedDummies(int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName("RelatedDummy #{$i}"); + $manager->persist($relatedDummy); + } + $manager->flush(); + $manager->clear(); + } + + private function seedRelatedDummiesWithThirdLevel(int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('John Doe'); + $relatedDummy->setAge(23); + $relatedDummy->thirdLevel = $thirdLevel; + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + } + $manager->flush(); + $manager->clear(); + } + + private function seedDummyWithTwoRelatedDummies(): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $relatedDummies = $manager->getRepository(RelatedDummy::class)->findBy([], ['id' => 'ASC']); + if (\count($relatedDummies) >= 2) { + $dummy->setRelatedDummy($relatedDummies[1]); + $dummy->addRelatedDummy($relatedDummies[0]); + $dummy->addRelatedDummy($relatedDummies[1]); + } + $manager->persist($dummy); + $manager->flush(); + $manager->clear(); + } +} diff --git a/tests/Functional/JsonApi/EntrypointTest.php b/tests/Functional/JsonApi/EntrypointTest.php new file mode 100644 index 00000000000..ddc220247a5 --- /dev/null +++ b/tests/Functional/JsonApi/EntrypointTest.php @@ -0,0 +1,56 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\EntrypointDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EntrypointTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [EntrypointDummy::class]; + } + + public function testEntrypointHasSelfAndResourceLinks(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('http://example.com/', $body['links']['self']); + $this->assertSame('http://example.com/jsonapi_entrypoint_dummies', $body['links']['jsonApiEntrypointDummy']); + } + + public function testEmptyCollectionRendersEmptyDataArray(): void + { + $response = self::createClient()->request('GET', '/jsonapi_entrypoint_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame([], $body['data']); + } +} diff --git a/tests/Functional/JsonApi/ErrorTest.php b/tests/Functional/JsonApi/ErrorTest.php new file mode 100644 index 00000000000..96a813c1d0b --- /dev/null +++ b/tests/Functional/JsonApi/ErrorTest.php @@ -0,0 +1,117 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ErrorProblem; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class ErrorTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonApiErrorTestResource::class, ErrorProblem::class]; + } + + public function testErrorResourceRendersInJsonApiFormat(): void + { + self::createClient()->request('GET', '/jsonapi_error_test/nonexistent', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'errors' => [ + [ + // TODO: change this to '400' in 5.x + 'status' => 400, + 'detail' => 'Resource "nonexistent" not found.', + ], + ], + ]); + } + + public function testValidationErrorRendersJsonApiPointer(): void + { + self::createClient()->request('POST', '/jsonapi_validation_problem', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiErrorProblem', + 'attributes' => new \stdClass(), + ], + ], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertJsonEquals([ + 'errors' => [ + [ + 'detail' => 'This value should not be blank.', + 'source' => ['pointer' => 'data/attributes/name'], + ], + ], + ]); + } + + public function testRfc7807ErrorRendersJsonApiFormat(): void + { + $response = self::createClient()->request('POST', '/jsonapi_exception_problem', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('An error occurred', $body['errors'][0]['title']); + $this->assertSame(400, $body['errors'][0]['status']); + $this->assertArrayHasKey('detail', $body['errors'][0]); + $this->assertArrayHasKey('type', $body['errors'][0]); + } + + public function testNotFoundRouteRendersJsonApiFormat(): void + { + $response = self::createClient()->request('POST', '/does_not_exist', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('An error occurred', $body['errors'][0]['title']); + $this->assertSame(404, $body['errors'][0]['status']); + $this->assertArrayHasKey('detail', $body['errors'][0]); + $this->assertArrayHasKey('type', $body['errors'][0]); + } +} diff --git a/tests/Functional/JsonApi/FilteringTest.php b/tests/Functional/JsonApi/FilteringTest.php new file mode 100644 index 00000000000..b44cb080998 --- /dev/null +++ b/tests/Functional/JsonApi/FilteringTest.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\FilteringDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\FilteringProperty; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class FilteringTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [FilteringDummy::class, FilteringProperty::class]; + } + + public function testFilterMatchesPaginatesToThree(): void + { + $response = self::createClient()->request('GET', '/jsonapi_filtering_dummies?filter[name]=my', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + } + + public function testFilterNoMatch(): void + { + $response = self::createClient()->request('GET', '/jsonapi_filtering_dummies?filter[name]=foo', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(0, $body['data']); + } + + public function testFilterAndPaginationCombined(): void + { + $response = self::createClient()->request('GET', '/jsonapi_filtering_dummies?filter[name]=foo&page[page]=2', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame(2, $body['meta']['currentPage']); + } + + public function testSparseFieldsetWithFields(): void + { + $response = self::createClient()->request( + 'GET', + '/jsonapi_filtering_properties?fields[JsonApiFilteringProperty]=id,foo,bar', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(2, $body['data']); + $this->assertSame('1', (string) $body['data'][0]['attributes']['_id']); + $this->assertSame('Foo #1', $body['data'][0]['attributes']['foo']); + $this->assertSame('Bar #1', $body['data'][0]['attributes']['bar']); + $this->assertArrayNotHasKey('group', $body['data'][0]['attributes']); + } + + public function testFilterDateAfter(): void + { + $response = self::createClient()->request( + 'GET', + '/jsonapi_filtering_dummies?filter[dummyDate][after]=2015-04-28', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(2, $body['data']); + } +} diff --git a/tests/Functional/JsonApi/IdentifierModeTest.php b/tests/Functional/JsonApi/IdentifierModeTest.php new file mode 100644 index 00000000000..2f34c41ea5a --- /dev/null +++ b/tests/Functional/JsonApi/IdentifierModeTest.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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiNotExposedRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRelatedDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class IdentifierModeTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + JsonApiDummy::class, + JsonApiRelatedDummy::class, + JsonApiNotExposedRelation::class, + ]; + } + + public function testGetSingleResourceIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'id' => '10', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/10', + ], + 'attributes' => [ + 'name' => 'Dummy #10', + ], + ], + ]); + } + + public function testGetCollectionIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + [ + 'id' => '1', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/1', + ], + ], + [ + 'id' => '2', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/2', + ], + ], + ], + ]); + } + + public function testRelationWithNotExposedOperationIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '10', + 'type' => 'JsonApiDummy', + 'relationships' => [ + 'notExposedRelation' => [ + 'data' => [ + 'id' => '5', + 'type' => 'JsonApiNotExposedRelation', + ], + ], + ], + ], + ]); + } + + public function testSubresourceNotExposedIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10/not_exposed_relation', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '5', + 'type' => 'JsonApiNotExposedRelation', + // links.self uses the subresource URI — the only publicly accessible route + 'links' => ['self' => '/jsonapi_dummies/10/not_exposed_relation'], + ], + ]); + } + + private function bootJsonApiKernel(): void + { + $baseEnv = $_SERVER['APP_ENV'] ?? 'test'; + $jsonApiEnv = 'mongodb' === $baseEnv ? 'jsonapi_mongodb' : 'jsonapi'; + + // AppKernel overrides environment with $_SERVER['APP_ENV'] (behat compat), + // so we must temporarily set it to our target environment. + $_SERVER['APP_ENV'] = $jsonApiEnv; + + try { + self::bootKernel(['environment' => $jsonApiEnv]); + } finally { + $_SERVER['APP_ENV'] = $baseEnv; + } + } +} diff --git a/tests/Functional/JsonApi/InputDtoTest.php b/tests/Functional/JsonApi/InputDtoTest.php new file mode 100644 index 00000000000..b42bab554ff --- /dev/null +++ b/tests/Functional/JsonApi/InputDtoTest.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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRequiredFieldsResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class InputDtoTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonApiInputResource::class, JsonApiRequiredFieldsResource::class]; + } + + /** + * Without the JSON:API ItemNormalizer guarding against double unwrapping, + * the second pass reads $data['data']['attributes'] from already-flat data + * and gets null, which nulls every DTO property. + */ + public function testPostWithInputDtoPreservesAttributes(): void + { + $response = self::createClient()->request('POST', '/jsonapi_input_test', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiInputResource', + 'attributes' => [ + 'title' => 'Hello from JSON:API', + 'body' => 'This should not be nulled.', + ], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'attributes' => [ + 'title' => 'Hello from JSON:API', + 'body' => 'This should not be nulled.', + ], + ], + ]); + } + + public function testPostWithRequiredConstructorArgsInputDto(): void + { + $response = self::createClient()->request('POST', '/jsonapi_required_fields_test', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiRequiredFieldsResource', + 'attributes' => [ + 'title' => 'Great review', + 'rating' => 5, + 'comment' => 'Loved it.', + ], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'attributes' => [ + 'title' => 'Great review', + 'rating' => 5, + 'comment' => 'Loved it.', + ], + ], + ]); + } +} diff --git a/tests/Functional/JsonApi/InputOutputTest.php b/tests/Functional/JsonApi/InputOutputTest.php new file mode 100644 index 00000000000..7cb8c5aa4bb --- /dev/null +++ b/tests/Functional/JsonApi/InputOutputTest.php @@ -0,0 +1,65 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\CustomOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CustomOutputResource::class]; + } + + public function testItemUsesCustomOutputDto(): void + { + $response = self::createClient()->request('GET', '/jsonapi_custom_outputs/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'type' => 'CustomOutputDto', + 'attributes' => [ + 'foo' => 'test', + 'bar' => 1, + ], + ], + ]); + } + + public function testCollectionUsesCustomOutputDto(): void + { + $response = self::createClient()->request('GET', '/jsonapi_custom_outputs', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + ['type' => 'CustomOutputDto', 'attributes' => ['foo' => 'test', 'bar' => 1]], + ['type' => 'CustomOutputDto', 'attributes' => ['foo' => 'test', 'bar' => 2]], + ], + ]); + } +} diff --git a/tests/Functional/JsonApi/IriModeTest.php b/tests/Functional/JsonApi/IriModeTest.php new file mode 100644 index 00000000000..429748ad16d --- /dev/null +++ b/tests/Functional/JsonApi/IriModeTest.php @@ -0,0 +1,52 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class IriModeTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonApiDummy::class]; + } + + public function testGetSingleResourceDefaultIriMode(): void + { + // Default mode (use_iri_as_id: true) — id is the IRI, no links.self + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/jsonapi_dummies/10', + 'type' => 'JsonApiDummy', + ], + ]); + + $json = json_decode(self::getClient()->getResponse()->getContent(), true); + $this->assertArrayNotHasKey('links', $json['data']); + } +} diff --git a/tests/Functional/JsonApi/ItemUriTemplateTest.php b/tests/Functional/JsonApi/ItemUriTemplateTest.php new file mode 100644 index 00000000000..d80ed9965b7 --- /dev/null +++ b/tests/Functional/JsonApi/ItemUriTemplateTest.php @@ -0,0 +1,98 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\UriTemplateCar; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ItemUriTemplateTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [UriTemplateCar::class]; + } + + public function testGetCollectionDerivesItemIriFromFirstGetOperation(): void + { + $response = self::createClient()->request('GET', '/jsonapi_uri_template_cars', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/jsonapi_uri_template_cars', $body['links']['self']); + $this->assertCount(2, $body['data']); + foreach ($body['data'] as $member) { + $this->assertMatchesRegularExpression('#^/jsonapi_uri_template_cars/.+$#', $member['id']); + $this->assertSame('JsonApiUriTemplateCar', $member['type']); + } + } + + public function testGetCollectionWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('GET', '/jsonapi_uri_template_brands/renault/cars', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/jsonapi_uri_template_brands/renault/cars', $body['links']['self']); + foreach ($body['data'] as $member) { + $this->assertMatchesRegularExpression('#^/jsonapi_uri_template_brands/renault/cars/.+$#', $member['id']); + } + } + + public function testPostWithoutItemUriTemplateUsesFirstGetOperation(): void + { + $response = self::createClient()->request('POST', '/jsonapi_uri_template_cars', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiUriTemplateCar', + 'attributes' => ['owner' => 'Vincent'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertMatchesRegularExpression('#^/jsonapi_uri_template_cars/.+$#', $body['data']['id']); + $this->assertSame('JsonApiUriTemplateCar', $body['data']['type']); + } + + public function testPostWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('POST', '/jsonapi_uri_template_brands/renault/cars', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiUriTemplateCar', + 'attributes' => ['owner' => 'Vincent'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertMatchesRegularExpression('#^/jsonapi_uri_template_brands/renault/cars/.+$#', $body['data']['id']); + } +} diff --git a/tests/Functional/JsonApi/NetworkPathTest.php b/tests/Functional/JsonApi/NetworkPathTest.php new file mode 100644 index 00000000000..d8b35fed8cb --- /dev/null +++ b/tests/Functional/JsonApi/NetworkPathTest.php @@ -0,0 +1,98 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NetworkPathDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NetworkPathRelationDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NetworkPathTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NetworkPathDummy::class, NetworkPathRelationDummy::class]; + } + + public function testCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_network_path_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonapi_network_path_dummies', $body['links']['self']); + $this->assertSame('//example.com/jsonapi_network_path_dummies/1', $body['data'][0]['id']); + $this->assertSame('JsonApiNetworkPathDummy', $body['data'][0]['type']); + $this->assertSame( + '//example.com/jsonapi_network_path_relation_dummies/1', + $body['data'][0]['relationships']['networkPathRelationDummy']['data']['id'], + ); + } + + public function testItemUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_network_path_dummies/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonapi_network_path_dummies/1', $body['data']['id']); + $this->assertSame('JsonApiNetworkPathDummy', $body['data']['type']); + $this->assertSame( + '//example.com/jsonapi_network_path_relation_dummies/1', + $body['data']['relationships']['networkPathRelationDummy']['data']['id'], + ); + } + + public function testPostReturnsNetworkPath(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonapi_network_path_relation_dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => ['data' => ['type' => 'JsonApiNetworkPathRelationDummy']], + ]); + + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonapi_network_path_relation_dummies/2', $body['data']['id']); + } + + public function testSubresourceCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_network_path_relation_dummies/1/network_path_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame( + '//example.com/jsonapi_network_path_relation_dummies/1/network_path_dummies', + $body['links']['self'], + ); + $this->assertSame('//example.com/jsonapi_network_path_dummies/1', $body['data'][0]['id']); + } +} diff --git a/tests/Functional/JsonApi/NonResourceTest.php b/tests/Functional/JsonApi/NonResourceTest.php new file mode 100644 index 00000000000..9f925752f6d --- /dev/null +++ b/tests/Functional/JsonApi/NonResourceTest.php @@ -0,0 +1,132 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NonRelationResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NonResourceContainer; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\PlainObjectResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NonResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NonResourceContainer::class, NonRelationResource::class, PlainObjectResource::class]; + } + + public function testNonResourceObjectIsEmbeddedAsRelationship(): void + { + $response = self::createClient()->request('GET', '/jsonapi_non_resource_containers/1?include=nested', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/jsonapi_non_resource_containers/1', + 'type' => 'JsonApiNonResourceContainer', + 'attributes' => [ + '_id' => '1', + 'notAResource' => ['foo' => 'f1', 'bar' => 'b1'], + ], + 'relationships' => [ + 'nested' => [ + 'data' => [ + 'id' => '/jsonapi_non_resource_containers/1-nested', + 'type' => 'JsonApiNonResourceContainer', + ], + ], + ], + ], + 'included' => [ + [ + 'id' => '/jsonapi_non_resource_containers/1-nested', + 'type' => 'JsonApiNonResourceContainer', + 'attributes' => [ + '_id' => '1-nested', + 'notAResource' => ['foo' => 'f2', 'bar' => 'b2'], + ], + ], + ], + ]); + } + + public function testCreateResourceWithNonResourceRelation(): void + { + $response = self::createClient()->request('POST', '/jsonapi_non_relation_resources', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiNonRelationResource', + 'attributes' => ['relation' => ['foo' => 'test']], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/jsonapi_non_relation_resources/1', + 'type' => 'JsonApiNonRelationResource', + 'attributes' => [ + '_id' => 1, + 'relation' => ['foo' => 'test'], + ], + ], + ]); + } + + public function testCreateResourceWithStdClass(): void + { + $payload = json_encode([ + 'fields' => [ + 'title' => ['value' => ''], + 'images' => [], + 'alternativeAudio' => new \stdClass(), + 'caption' => '', + ], + 'showCaption' => false, + 'alternativeContent' => false, + 'alternativeAudioContent' => false, + 'blockLayout' => 'default', + ]); + + $response = self::createClient()->request('POST', '/jsonapi_plain_object_resources', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiPlainObjectResource', + 'attributes' => ['content' => $payload], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/jsonapi_plain_object_resources/1', $body['data']['id']); + $this->assertSame('JsonApiPlainObjectResource', $body['data']['type']); + $this->assertFalse($body['data']['attributes']['data']['showCaption']); + $this->assertSame('default', $body['data']['attributes']['data']['blockLayout']); + } +} diff --git a/tests/Functional/JsonApi/OrderingTest.php b/tests/Functional/JsonApi/OrderingTest.php new file mode 100644 index 00000000000..dbd6ca7480b --- /dev/null +++ b/tests/Functional/JsonApi/OrderingTest.php @@ -0,0 +1,63 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\OrderingDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OrderingTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [OrderingDummy::class]; + } + + public function testSortAscendingOnSingleField(): void + { + $response = self::createClient()->request('GET', '/jsonapi_ordering_dummies?sort=id', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $ids = array_map(static fn (array $d): int => (int) $d['attributes']['_id'], $body['data']); + $this->assertSame([1, 2, 3], \array_slice($ids, 0, 3)); + } + + public function testSortDescendingOnSingleField(): void + { + $response = self::createClient()->request('GET', '/jsonapi_ordering_dummies?sort=-id', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $ids = array_map(static fn (array $d): int => (int) $d['attributes']['_id'], $body['data']); + $this->assertSame([30, 29, 28], \array_slice($ids, 0, 3)); + } + + public function testSortMultipleFields(): void + { + $response = self::createClient()->request('GET', '/jsonapi_ordering_dummies?sort=description,-id', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $ids = array_map(static fn (array $d): int => (int) $d['attributes']['_id'], $body['data']); + $this->assertSame([30, 28, 26], \array_slice($ids, 0, 3)); + } +} diff --git a/tests/Functional/JsonApi/PaginationTest.php b/tests/Functional/JsonApi/PaginationTest.php new file mode 100644 index 00000000000..a57eaff9368 --- /dev/null +++ b/tests/Functional/JsonApi/PaginationTest.php @@ -0,0 +1,83 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\PaginationDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PaginationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [PaginationDummy::class]; + } + + public function testFirstPageDefaults(): void + { + $response = self::createClient()->request('GET', '/jsonapi_pagination_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + $this->assertSame(10, $body['meta']['totalItems']); + $this->assertSame(3, $body['meta']['itemsPerPage']); + $this->assertSame(1, $body['meta']['currentPage']); + } + + public function testFourthPage(): void + { + $response = self::createClient()->request('GET', '/jsonapi_pagination_dummies?page[page]=4', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(1, $body['data']); + $this->assertSame(4, $body['meta']['currentPage']); + } + + public function testCustomItemsPerPage(): void + { + $response = self::createClient()->request('GET', '/jsonapi_pagination_dummies?page[itemsPerPage]=15', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(10, $body['data']); + $this->assertSame(10, $body['meta']['totalItems']); + $this->assertSame(15, $body['meta']['itemsPerPage']); + $this->assertSame(1, $body['meta']['currentPage']); + } + + public function testInvalidPageNumberZero(): void + { + self::createClient()->request('GET', '/jsonapi_pagination_dummies?page[page]=0', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseStatusCodeSame(400); + } + + public function testTooLargePageNumber(): void + { + self::createClient()->request('GET', '/jsonapi_pagination_dummies?page[page]=9223372036854775807', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseStatusCodeSame(400); + } +} diff --git a/tests/Functional/JsonApi/RelatedResourcesInclusionTest.php b/tests/Functional/JsonApi/RelatedResourcesInclusionTest.php new file mode 100644 index 00000000000..b12ab711bfe --- /dev/null +++ b/tests/Functional/JsonApi/RelatedResourcesInclusionTest.php @@ -0,0 +1,591 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class RelatedResourcesInclusionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + Dummy::class, + DummyProperty::class, + DummyGroup::class, + RelatedDummy::class, + ThirdLevel::class, + FourthLevel::class, + RelatedOwningDummy::class, + RelatedOwnedDummy::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema(self::getResources()); + } + + public function testIncludeManyToOneRelation(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request('GET', '/dummy_properties/1?include=group', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonEquals([ + 'data' => [ + 'id' => '/dummy_properties/1', + 'type' => 'DummyProperty', + 'attributes' => [ + '_id' => 1, + 'foo' => 'Foo #1', + 'bar' => 'Bar #1', + 'baz' => 'Baz #1', + 'name_converted' => 'NameConverted #1', + ], + 'relationships' => [ + 'group' => [ + 'data' => ['type' => 'DummyGroup', 'id' => '/dummy_groups/1'], + ], + 'groups' => ['data' => []], + ], + ], + 'included' => [ + [ + 'id' => '/dummy_groups/1', + 'type' => 'DummyGroup', + 'attributes' => [ + '_id' => 1, + 'foo' => 'Foo #1', + 'bar' => 'Bar #1', + 'baz' => 'Baz #1', + ], + ], + ], + ]); + } + + public function testIncludeNonExistingRelation(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request('GET', '/dummy_properties/1?include=foo', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/dummy_properties/1', $body['data']['id']); + $this->assertArrayNotHasKey('included', $body); + } + + public function testIncludeKeepsMainAttributesUnfiltered(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request( + 'GET', + '/dummy_properties/1?include=group&fields[group]=id,foo&fields[DummyProperty]=bar,baz', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + 'data' => [ + 'id' => '/dummy_properties/1', + 'type' => 'DummyProperty', + 'attributes' => ['bar' => 'Bar #1', 'baz' => 'Baz #1'], + 'relationships' => [ + 'group' => ['data' => ['type' => 'DummyGroup', 'id' => '/dummy_groups/1']], + ], + ], + 'included' => [ + [ + 'id' => '/dummy_groups/1', + 'type' => 'DummyGroup', + 'attributes' => ['_id' => 1, 'foo' => 'Foo #1'], + ], + ], + ]); + } + + public function testIncludeWithSparseFieldsForRelationOnly(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request( + 'GET', + '/dummy_properties/1?include=group&fields[group]=id,foo', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + 'data' => [ + 'id' => '/dummy_properties/1', + 'type' => 'DummyProperty', + 'relationships' => [ + 'group' => ['data' => ['type' => 'DummyGroup', 'id' => '/dummy_groups/1']], + ], + ], + 'included' => [ + [ + 'id' => '/dummy_groups/1', + 'type' => 'DummyGroup', + 'attributes' => ['_id' => 1, 'foo' => 'Foo #1'], + ], + ], + ]); + } + + public function testIncludeManyToMany(): void + { + $this->seedDummyPropertyObjectsWithGroups(1, 3); + + $response = self::createClient()->request('GET', '/dummy_properties/1?include=groups', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']['relationships']['groups']['data']); + $this->assertCount(3, $body['included']); + $includedIds = array_column($body['included'], 'id'); + $this->assertContains('/dummy_groups/2', $includedIds); + $this->assertContains('/dummy_groups/3', $includedIds); + $this->assertContains('/dummy_groups/4', $includedIds); + } + + public function testIncludeManyToManyAndManyToOne(): void + { + $this->seedDummyPropertyObjectsWithGroups(1, 3); + + $response = self::createClient()->request('GET', '/dummy_properties/1?include=groups,group', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + // 1 group (manyToOne) + 3 groups (manyToMany) = 4 included. + $this->assertCount(4, $body['included']); + $includedIds = array_column($body['included'], 'id'); + $this->assertContains('/dummy_groups/1', $includedIds); + $this->assertContains('/dummy_groups/2', $includedIds); + $this->assertContains('/dummy_groups/3', $includedIds); + $this->assertContains('/dummy_groups/4', $includedIds); + } + + public function testIncludeRelatedDummyAndItsThirdLevel(): void + { + $this->seedDummiesWithRelatedDummyAndThirdLevel(1); + + $response = self::createClient()->request('GET', '/dummies/1?include=relatedDummy', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/dummies/1', $body['data']['id']); + $this->assertSame('Dummy', $body['data']['type']); + $this->assertSame( + '/related_dummies/1', + $body['data']['relationships']['relatedDummy']['data']['id'], + ); + $this->assertCount(1, $body['included']); + $this->assertSame('/related_dummies/1', $body['included'][0]['id']); + $this->assertSame('RelatedDummy', $body['included'][0]['type']); + $this->assertSame('RelatedDummy #1', $body['included'][0]['attributes']['name']); + $this->assertSame( + '/third_levels/1', + $body['included'][0]['relationships']['thirdLevel']['data']['id'], + ); + } + + public function testIncludeFromPath(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request( + 'GET', + '/dummies/1?include=relatedDummy.thirdLevel.fourthLevel', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/dummies/1', $body['data']['id']); + $includedIds = array_column($body['included'], 'id'); + // relatedDummy + thirdLevel + fourthLevel + $this->assertContains('/related_dummies/1', $includedIds); + $this->assertContains('/third_levels/1', $includedIds); + $this->assertContains('/fourth_levels/1', $includedIds); + $this->assertCount(3, $body['included']); + } + + public function testIncludeFromPathWithCollection(): void + { + $this->seedDummyWithRelatedDummiesAndTheirThirdLevel(3); + + $response = self::createClient()->request( + 'GET', + '/dummies/1?include=relatedDummies.thirdLevel', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']['relationships']['relatedDummies']['data']); + $includedIds = array_column($body['included'], 'id'); + // 3 related_dummies + 3 third_levels (each its own) = 6 + $this->assertCount(6, $body['included']); + $this->assertContains('/related_dummies/1', $includedIds); + $this->assertContains('/related_dummies/2', $includedIds); + $this->assertContains('/related_dummies/3', $includedIds); + $this->assertContains('/third_levels/1', $includedIds); + $this->assertContains('/third_levels/2', $includedIds); + $this->assertContains('/third_levels/3', $includedIds); + } + + public function testIncludeDoesNotIncludeRequestedResource(): void + { + $this->seedRelatedOwningDummyOneToOne(); + + $response = self::createClient()->request( + 'GET', + '/dummies/1?include=relatedOwningDummy.ownedDummy', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/dummies/1', $body['data']['id']); + $this->assertSame( + '/related_owning_dummies/1', + $body['data']['relationships']['relatedOwningDummy']['data']['id'], + ); + // Path leads back to the requested resource — only RelatedOwningDummy stays in included, not Dummy itself. + $includedIds = array_column($body['included'], 'id'); + $this->assertCount(1, $body['included']); + $this->assertSame('/related_owning_dummies/1', $body['included'][0]['id']); + $this->assertNotContains('/dummies/1', $includedIds); + } + + public function testIncludeDoesNotDuplicateSharedThirdLevel(): void + { + $this->seedDummyWithRelatedDummiesSharingThirdLevel(3); + + $response = self::createClient()->request( + 'GET', + '/dummies/1?include=relatedDummies.thirdLevel', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']['relationships']['relatedDummies']['data']); + $includedIds = array_column($body['included'], 'id'); + // 3 related_dummies + 1 shared third_level = 4 included entries. + $this->assertCount(4, $body['included']); + $thirdLevelEntries = array_filter($body['included'], static fn (array $e): bool => 'ThirdLevel' === $e['type']); + $this->assertCount(1, $thirdLevelEntries); + } + + public function testIncludeRelationOnCollection(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request('GET', '/dummy_properties?include=group', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + // Each property has its own group → 3 included. + $this->assertCount(3, $body['included']); + $includedIds = array_column($body['included'], 'id'); + $this->assertContains('/dummy_groups/1', $includedIds); + $this->assertContains('/dummy_groups/2', $includedIds); + $this->assertContains('/dummy_groups/3', $includedIds); + } + + public function testIncludeOnCollectionDeduplicatesSharedRelation(): void + { + $this->seedDummyPropertyObjectsWithSharedGroup(3); + + $response = self::createClient()->request('GET', '/dummy_properties?include=group', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + // All 3 properties share 1 group → only 1 included entry. + $this->assertCount(1, $body['included']); + $this->assertSame('/dummy_groups/1', $body['included'][0]['id']); + } + + public function testIncludeOnCollectionWithDifferingNumberOfGroupsDeduplicates(): void + { + $this->seedDummyPropertyObjectsWithDifferentNumberOfRelatedGroups(2); + + $response = self::createClient()->request('GET', '/dummy_properties?include=groups', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(2, $body['data']); + // Property 1 has [group1]; property 2 has [group1, group2]. Dedup → 2 unique groups. + $this->assertCount(2, $body['included']); + $includedIds = array_column($body['included'], 'id'); + $this->assertContains('/dummy_groups/1', $includedIds); + $this->assertContains('/dummy_groups/2', $includedIds); + } + + public function testIncludeFromPathOnCollection(): void + { + $this->seedDummiesWithRelatedDummyAndThirdLevel(3); + + $response = self::createClient()->request( + 'GET', + '/dummies?include=relatedDummy.thirdLevel', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + $includedIds = array_column($body['included'], 'id'); + // 3 related dummies + 3 distinct thirdLevels = 6. + $this->assertCount(6, $body['included']); + $this->assertContains('/related_dummies/1', $includedIds); + $this->assertContains('/third_levels/1', $includedIds); + } + + private function seedDummyPropertyObjects(int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummyProperty = new DummyProperty(); + $dummyGroup = new DummyGroup(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property)." #{$i}"; + } + $dummyProperty->nameConverted = "NameConverted #{$i}"; + $dummyProperty->group = $dummyGroup; + $manager->persist($dummyGroup); + $manager->persist($dummyProperty); + } + $manager->flush(); + } + + private function seedDummyPropertyObjectsWithGroups(int $nb, int $nb2): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummyProperty = new DummyProperty(); + $dummyGroup = new DummyGroup(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property)." #{$i}"; + } + $dummyProperty->group = $dummyGroup; + $manager->persist($dummyGroup); + + $dummyProperty->groups = []; + for ($j = 1; $j <= $nb2; ++$j) { + $extraGroup = new DummyGroup(); + foreach (['foo', 'bar', 'baz'] as $property) { + $extraGroup->{$property} = ucfirst($property).' #'.$i.$j; + } + $dummyProperty->groups[] = $extraGroup; + $manager->persist($extraGroup); + } + $manager->persist($dummyProperty); + } + $manager->flush(); + } + + private function seedDummyPropertyObjectsWithSharedGroup(int $nb): void + { + $manager = $this->getManager(); + $dummyGroup = new DummyGroup(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyGroup->{$property} = ucfirst($property).' #shared'; + } + $manager->persist($dummyGroup); + + for ($i = 1; $i <= $nb; ++$i) { + $dummyProperty = new DummyProperty(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyProperty->{$property} = ucfirst($property)." #{$i}"; + } + $dummyProperty->group = $dummyGroup; + $manager->persist($dummyProperty); + } + $manager->flush(); + } + + private function seedDummyPropertyObjectsWithDifferentNumberOfRelatedGroups(int $nb): void + { + $manager = $this->getManager(); + $dummyGroups = []; + for ($i = 1; $i <= $nb; ++$i) { + $dummyGroup = new DummyGroup(); + $dummyProperty = new DummyProperty(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property)." #{$i}"; + } + $manager->persist($dummyGroup); + $dummyGroups[$i] = $dummyGroup; + + $dummyProperty->groups = []; + for ($j = 1; $j <= $i; ++$j) { + $dummyProperty->groups[] = $dummyGroups[$j]; + } + $manager->persist($dummyProperty); + } + $manager->flush(); + } + + private function seedDummiesWithRelatedDummyAndThirdLevel(int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName("RelatedDummy #{$i}"); + $relatedDummy->thirdLevel = $thirdLevel; + + $dummy = new Dummy(); + $dummy->setName("Dummy #{$i}"); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setRelatedDummy($relatedDummy); + + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummyWithFourthLevel(): void + { + $manager = $this->getManager(); + + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->thirdLevel = $thirdLevel; + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->thirdLevel = $thirdLevel; + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + // Detach so the request side hydrates from DB instead of seeing in-memory + // FourthLevel.badThirdLevel left at its declared null default. + $manager->clear(); + } + + private function seedDummyWithRelatedDummiesAndTheirThirdLevel(int $nb): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName("RelatedDummy #{$i}"); + $relatedDummy->thirdLevel = $thirdLevel; + $dummy->addRelatedDummy($relatedDummy); + + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + } + $manager->persist($dummy); + $manager->flush(); + } + + private function seedDummyWithRelatedDummiesSharingThirdLevel(int $nb): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $thirdLevel = new ThirdLevel(); + + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName("RelatedDummy #{$i}"); + $relatedDummy->thirdLevel = $thirdLevel; + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($relatedDummy); + } + $manager->persist($thirdLevel); + $manager->persist($dummy); + $manager->flush(); + } + + private function seedRelatedOwningDummyOneToOne(): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('plop'); + $manager->persist($dummy); + + $relatedOwningDummy = new RelatedOwningDummy(); + $relatedOwningDummy->setOwnedDummy($dummy); + $manager->persist($relatedOwningDummy); + $manager->flush(); + } +} diff --git a/tests/Functional/JsonApiTest.php b/tests/Functional/JsonApiTest.php deleted file mode 100644 index 66d35696b97..00000000000 --- a/tests/Functional/JsonApiTest.php +++ /dev/null @@ -1,270 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Functional; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiInputResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiNotExposedRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRequiredFieldsResource; -use ApiPlatform\Tests\SetupClassResourcesTrait; - -class JsonApiTest extends ApiTestCase -{ - use SetupClassResourcesTrait; - protected static ?bool $alwaysBootKernel = false; - - /** - * @return class-string[] - */ - public static function getResources(): array - { - return [ - JsonApiErrorTestResource::class, - JsonApiDummy::class, - JsonApiRelatedDummy::class, - JsonApiNotExposedRelation::class, - JsonApiInputResource::class, - JsonApiRequiredFieldsResource::class, - ]; - } - - public function testError(): void - { - self::createClient()->request('GET', '/jsonapi_error_test/nonexistent', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseStatusCodeSame(400); - $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'errors' => [ - [ - // TODO: change this to '400' in 5.x - 'status' => 400, - 'detail' => 'Resource "nonexistent" not found.', - ], - ], - ]); - } - - public function testGetSingleResourceIdentifierMode(): void - { - $this->bootJsonApiKernel(); - self::createClient()->request('GET', '/jsonapi_dummies/10', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'data' => [ - 'id' => '10', - 'type' => 'JsonApiDummy', - 'links' => [ - 'self' => '/jsonapi_dummies/10', - ], - 'attributes' => [ - 'name' => 'Dummy #10', - ], - ], - ]); - } - - public function testGetCollectionIdentifierMode(): void - { - $this->bootJsonApiKernel(); - self::createClient()->request('GET', '/jsonapi_dummies', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'data' => [ - [ - 'id' => '1', - 'type' => 'JsonApiDummy', - 'links' => [ - 'self' => '/jsonapi_dummies/1', - ], - ], - [ - 'id' => '2', - 'type' => 'JsonApiDummy', - 'links' => [ - 'self' => '/jsonapi_dummies/2', - ], - ], - ], - ]); - } - - public function testRelationWithNotExposedOperationIdentifierMode(): void - { - $this->bootJsonApiKernel(); - self::createClient()->request('GET', '/jsonapi_dummies/10', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'data' => [ - 'id' => '10', - 'type' => 'JsonApiDummy', - 'relationships' => [ - 'notExposedRelation' => [ - 'data' => [ - 'id' => '5', - 'type' => 'JsonApiNotExposedRelation', - ], - ], - ], - ], - ]); - } - - public function testSubresourceNotExposedIdentifierMode(): void - { - $this->bootJsonApiKernel(); - self::createClient()->request('GET', '/jsonapi_dummies/10/not_exposed_relation', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'data' => [ - 'id' => '5', - 'type' => 'JsonApiNotExposedRelation', - // links.self uses the subresource URI — the only publicly accessible route - 'links' => ['self' => '/jsonapi_dummies/10/not_exposed_relation'], - ], - ]); - } - - public function testGetSingleResourceDefaultIriMode(): void - { - // Default mode (use_iri_as_id: true) — id should be the IRI, no links.self - self::createClient()->request('GET', '/jsonapi_dummies/10', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'data' => [ - 'id' => '/jsonapi_dummies/10', - 'type' => 'JsonApiDummy', - ], - ]); - - // Verify no links.self is present on the data object - $json = json_decode(self::getClient()->getResponse()->getContent(), true); - $this->assertArrayNotHasKey('links', $json['data']); - } - - /** - * Reproducer for https://github.com/api-platform/core/issues/7794. - * - * When using an input DTO with JSON:API format, the JsonApi\ItemNormalizer - * must not unwrap data.attributes twice. Without the fix, the second pass - * reads $data['data']['attributes'] from already-flat data and gets null, - * which nulls every DTO property. - */ - public function testPostWithInputDtoPreservesAttributes(): void - { - $response = self::createClient()->request('POST', '/jsonapi_input_test', [ - 'headers' => [ - 'accept' => 'application/vnd.api+json', - 'content-type' => 'application/vnd.api+json', - ], - 'json' => [ - 'data' => [ - 'type' => 'JsonApiInputResource', - 'attributes' => [ - 'title' => 'Hello from JSON:API', - 'body' => 'This should not be nulled.', - ], - ], - ], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'data' => [ - 'attributes' => [ - 'title' => 'Hello from JSON:API', - 'body' => 'This should not be nulled.', - ], - ], - ]); - } - - /** - * Verify that a JSON:API POST with all required fields on an input DTO - * with constructor arguments works correctly end-to-end. - * - * Related to Sylius test failures caused by a missing `continue` in - * AbstractItemNormalizer::instantiateObject() — only the first missing - * constructor argument was reported instead of all of them. - */ - public function testPostWithRequiredConstructorArgsInputDto(): void - { - $response = self::createClient()->request('POST', '/jsonapi_required_fields_test', [ - 'headers' => [ - 'accept' => 'application/vnd.api+json', - 'content-type' => 'application/vnd.api+json', - ], - 'json' => [ - 'data' => [ - 'type' => 'JsonApiRequiredFieldsResource', - 'attributes' => [ - 'title' => 'Great review', - 'rating' => 5, - 'comment' => 'Loved it.', - ], - ], - ], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'data' => [ - 'attributes' => [ - 'title' => 'Great review', - 'rating' => 5, - 'comment' => 'Loved it.', - ], - ], - ]); - } - - private function bootJsonApiKernel(): void - { - $baseEnv = $_SERVER['APP_ENV'] ?? 'test'; - $jsonApiEnv = 'mongodb' === $baseEnv ? 'jsonapi_mongodb' : 'jsonapi'; - - // AppKernel overrides environment with $_SERVER['APP_ENV'] (behat compat), - // so we must temporarily set it to our target environment. - $_SERVER['APP_ENV'] = $jsonApiEnv; - - try { - self::bootKernel(['environment' => $jsonApiEnv]); - } finally { - $_SERVER['APP_ENV'] = $baseEnv; - } - } -} diff --git a/tests/Functional/JsonLd/AbsolutePaginationTest.php b/tests/Functional/JsonLd/AbsolutePaginationTest.php new file mode 100644 index 00000000000..d117647a7e0 --- /dev/null +++ b/tests/Functional/JsonLd/AbsolutePaginationTest.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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\AbsolutePagedResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AbsolutePaginationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [AbsolutePagedResource::class]; + } + + public function testHydraViewUrlsAreAbsolute(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_absolute_paged?page=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([ + '@id' => 'http://example.com/jsonld_absolute_paged?page=3', + '@type' => 'hydra:PartialCollectionView', + 'hydra:first' => 'http://example.com/jsonld_absolute_paged?page=1', + 'hydra:last' => 'http://example.com/jsonld_absolute_paged?page=10', + 'hydra:previous' => 'http://example.com/jsonld_absolute_paged?page=2', + 'hydra:next' => 'http://example.com/jsonld_absolute_paged?page=4', + ], $body['hydra:view']); + } +} diff --git a/tests/Functional/JsonLd/AbsoluteUrlTest.php b/tests/Functional/JsonLd/AbsoluteUrlTest.php new file mode 100644 index 00000000000..e94c2e5ce35 --- /dev/null +++ b/tests/Functional/JsonLd/AbsoluteUrlTest.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\AbsoluteUrlChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\AbsoluteUrlParent; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AbsoluteUrlTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [AbsoluteUrlChild::class, AbsoluteUrlParent::class]; + } + + public function testCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_absolute_url_children', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/contexts/JsonLdAbsoluteUrlChild', $body['@context']); + $this->assertSame('http://example.com/jsonld_absolute_url_children', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame('http://example.com/jsonld_absolute_url_children/1', $body['hydra:member'][0]['@id']); + } + + public function testItemUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_absolute_url_children/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/contexts/JsonLdAbsoluteUrlChild', $body['@context']); + $this->assertSame('http://example.com/jsonld_absolute_url_children/1', $body['@id']); + $this->assertSame('JsonLdAbsoluteUrlChild', $body['@type']); + $this->assertSame('http://example.com/jsonld_absolute_url_parents/1', $body['parent']); + } + + public function testPostAcceptsAbsoluteUrlInPayload(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonld_absolute_url_children', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['parent' => 'http://example.com/jsonld_absolute_url_parents/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('http://example.com/jsonld_absolute_url_children/2', $body['@id']); + $this->assertSame('JsonLdAbsoluteUrlChild', $body['@type']); + $this->assertSame('http://example.com/jsonld_absolute_url_parents/1', $body['parent']); + } + + public function testSubresourceCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_absolute_url_parents/1/children', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/contexts/JsonLdAbsoluteUrlChild', $body['@context']); + $this->assertSame('http://example.com/jsonld_absolute_url_parents/1/children', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + } +} diff --git a/tests/Functional/JsonLd/ContextOutputTest.php b/tests/Functional/JsonLd/ContextOutputTest.php new file mode 100644 index 00000000000..07a6f4f9c82 --- /dev/null +++ b/tests/Functional/JsonLd/ContextOutputTest.php @@ -0,0 +1,51 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\GenIdFalse; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6810\JsonLdContextOutput; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class ContextOutputTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonLdContextOutput::class, GenIdFalse::class]; + } + + public function testContextOnOutputDtoMatchesDeclaredVocabulary(): void + { + $response = self::createClient()->request('GET', '/json_ld_context_output'); + $res = $response->toArray(); + $this->assertEquals($res['@context'], [ + '@vocab' => 'http://localhost/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'foo' => 'Output/foo', + ]); + } + + public function testIgnoredPropertyIsExcludedFromResourceContext(): void + { + $r = self::createClient()->request('GET', '/contexts/GenIdFalse'); + $this->assertArrayNotHasKey('shouldBeIgnored', $r->toArray()['@context']); + } +} diff --git a/tests/Functional/JsonLd/ContextTest.php b/tests/Functional/JsonLd/ContextTest.php new file mode 100644 index 00000000000..dd768a9cf55 --- /dev/null +++ b/tests/Functional/JsonLd/ContextTest.php @@ -0,0 +1,115 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonLdContextDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonLdContextRelation; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ContextTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [JsonLdContextDummy::class, JsonLdContextRelation::class]; + } + + public function testEntrypointContextListsResources(): void + { + $response = self::createClient()->request('GET', '/contexts/Entrypoint'); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('http://localhost/docs.jsonld#', $body['@context']['@vocab']); + $this->assertSame('http://www.w3.org/ns/hydra/core#', $body['@context']['hydra']); + $this->assertSame(['@id' => 'Entrypoint/jsonLdContextDummy', '@type' => '@id'], $body['@context']['jsonLdContextDummy']); + $this->assertSame(['@id' => 'Entrypoint/jsonLdContextRelation', '@type' => '@id'], $body['@context']['jsonLdContextRelation']); + } + + public function testResourceContextExposesPropertyMappings(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://localhost/docs.jsonld#', $body['@context']['@vocab']); + $this->assertSame('http://www.w3.org/ns/hydra/core#', $body['@context']['hydra']); + $this->assertSame('https://schema.org/name', $body['@context']['name']); + $this->assertSame('https://schema.org/alternateName', $body['@context']['alias']); + $this->assertSame([ + '@id' => 'https://example.com/id', + '@type' => '@id', + 'foo' => 'bar', + ], $body['@context']['person']); + } + + public function testRelatedResourceMappingHasIdReference(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([ + '@id' => 'JsonLdContextDummy/related', + '@type' => '@id', + ], $body['@context']['related']); + } + + public function testRelatedCollectionMappingHasIdReference(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([ + '@id' => 'JsonLdContextDummy/relatedCollection', + '@type' => '@id', + ], $body['@context']['relatedCollection']); + } + + public function testDateTimePropertyExposesSchemaOrgDateTime(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('https://schema.org/DateTime', $body['@context']['dummyDate']); + } + + public function testNameConvertedPropertyKeyIsNormalized(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('name_converted', $body['@context']); + $this->assertArrayNotHasKey('nameConverted', $body['@context']); + } + + public function testJsonAndArrayDataAreExposed(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('jsonData', $body['@context']); + $this->assertArrayHasKey('arrayData', $body['@context']); + } + + public function testEmbeddedRelationMappingIsPlainString(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('JsonLdContextDummy/embedded', $body['@context']['embedded']); + } +} diff --git a/tests/Functional/JsonLd/CursorPaginationTest.php b/tests/Functional/JsonLd/CursorPaginationTest.php new file mode 100644 index 00000000000..dfa41f81198 --- /dev/null +++ b/tests/Functional/JsonLd/CursorPaginationTest.php @@ -0,0 +1,104 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CursorPaginationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [SoMany::class]; + } + + public function testEmptyCollectionWithCursorPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SoMany::class]); + + $response = self::createClient()->request('GET', '/so_manies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/SoMany', $body['@context']); + $this->assertSame('/so_manies', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame('/so_manies', $body['hydra:view']['@id']); + $this->assertSame('hydra:PartialCollectionView', $body['hydra:view']['@type']); + $this->assertCount(0, $body['hydra:member']); + } + + public function testRangedItemsWithCursorPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SoMany::class]); + $manager = $this->getManager(); + for ($i = 1; $i <= 10; ++$i) { + $s = new SoMany(); + $s->content = "row $i"; + $manager->persist($s); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/so_manies?order[id]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/so_manies?order%5Bid%5D=desc', $body['hydra:view']['@id']); + $this->assertSame('/so_manies?order%5Bid%5D=desc&id%5Bgt%5D=10', $body['hydra:view']['hydra:previous']); + $this->assertSame('/so_manies?order%5Bid%5D=desc&id%5Blt%5D=8', $body['hydra:view']['hydra:next']); + $this->assertGreaterThanOrEqual(3, \count($body['hydra:member'])); + } + + public function testRangeFilteredItemsWithCursorPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SoMany::class]); + $manager = $this->getManager(); + for ($i = 1; $i <= 10; ++$i) { + $s = new SoMany(); + $s->content = "row $i"; + $manager->persist($s); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/so_manies?order[id]=desc&id[gt]=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(0, $body['hydra:member']); + $this->assertSame('/so_manies?id%5Bgt%5D=10&order%5Bid%5D=desc', $body['hydra:view']['@id']); + $this->assertSame('hydra:PartialCollectionView', $body['hydra:view']['@type']); + } +} diff --git a/tests/Functional/JsonLd/DisableIdGenerationTest.php b/tests/Functional/JsonLd/DisableIdGenerationTest.php new file mode 100644 index 00000000000..d0d9759bc27 --- /dev/null +++ b/tests/Functional/JsonLd/DisableIdGenerationTest.php @@ -0,0 +1,41 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DisableIdGenAnonymous; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class DisableIdGenerationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [DisableIdGenAnonymous::class]; + } + + public function testNestedAnonymousResourceHasNoIri(): void + { + $response = self::createClient()->request('GET', '/jsonld_disable_id_gen_anonymous', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $items = $response->toArray()['items']; + $this->assertArrayNotHasKey('@id', $items[0]); + $this->assertArrayNotHasKey('@id', $items[1]); + } +} diff --git a/tests/Functional/JsonLd/EntityClassWithDateTimeTest.php b/tests/Functional/JsonLd/EntityClassWithDateTimeTest.php new file mode 100644 index 00000000000..5163f098908 --- /dev/null +++ b/tests/Functional/JsonLd/EntityClassWithDateTimeTest.php @@ -0,0 +1,59 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EntityClassWithDateTime; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EntityClassWithDateTime as EntityClassWithDateTimeEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EntityClassWithDateTimeTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [EntityClassWithDateTime::class]; + } + + public function testGetExposesDateTimeProperty(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([EntityClassWithDateTimeEntity::class]); + + $manager = $this->getManager(); + $entity = new EntityClassWithDateTimeEntity(); + $entity->setStart(new \DateTime('2024-05-12T10:00:00+00:00')); + $manager->persist($entity); + $manager->flush(); + + $response = self::createClient()->request('GET', '/EntityClassWithDateTime/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('/EntityClassWithDateTime/1', $body['@id']); + $this->assertSame('EntityClassWithDateTime', $body['@type']); + $this->assertArrayHasKey('start', $body); + $this->assertNotEmpty($body['start']); + } +} diff --git a/tests/Functional/JsonLd/EntrypointTest.php b/tests/Functional/JsonLd/EntrypointTest.php new file mode 100644 index 00000000000..502f464557a --- /dev/null +++ b/tests/Functional/JsonLd/EntrypointTest.php @@ -0,0 +1,46 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonLdContextDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonLdContextRelation; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EntrypointTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [JsonLdContextDummy::class, JsonLdContextRelation::class]; + } + + public function testEntrypointListsRegisteredResources(): void + { + $response = self::createClient()->request('GET', '/', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('/contexts/Entrypoint', $body['@context']); + $this->assertSame('/', $body['@id']); + $this->assertSame('Entrypoint', $body['@type']); + $this->assertSame('/jsonld_context_relations', $body['jsonLdContextRelation']); + $this->assertArrayHasKey('jsonLdContextDummy', $body); + } +} diff --git a/tests/Functional/JsonLd/GenIdFalseTest.php b/tests/Functional/JsonLd/GenIdFalseTest.php new file mode 100644 index 00000000000..7cedc762669 --- /dev/null +++ b/tests/Functional/JsonLd/GenIdFalseTest.php @@ -0,0 +1,53 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\AggregateRating; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\GenIdFalse; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\LevelFirst; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\LevelThird; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class GenIdFalseTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [GenIdFalse::class, AggregateRating::class, LevelFirst::class, LevelThird::class]; + } + + public function testNestedResourceWithGenIdFalseHasNoIdProperty(): void + { + $r = self::createClient()->request('GET', '/gen_id_falsy'); + $this->assertJsonContains([ + 'aggregateRating' => ['ratingValue' => 2, 'ratingCount' => 3], + ]); + $this->assertArrayNotHasKey('@id', $r->toArray()['aggregateRating']); + } + + public function testGenIdFalseAppliesOnlyToConfiguredLevel(): void + { + $r = self::createClient()->request('GET', '/levelfirst/1'); + $res = $r->toArray(); + $this->assertArrayNotHasKey('@id', $res['levelSecond']); + $this->assertArrayHasKey('@id', $res['levelSecond'][0]['levelThird']); + } +} diff --git a/tests/Functional/JsonLd/HydraCollectionTest.php b/tests/Functional/JsonLd/HydraCollectionTest.php new file mode 100644 index 00000000000..e97add70b24 --- /dev/null +++ b/tests/Functional/JsonLd/HydraCollectionTest.php @@ -0,0 +1,205 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\CollectionNoPrefix; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\CollectionPagedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PaginationCapped; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HydraCollectionTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CollectionPagedResource::class, CollectionNoPrefix::class, PaginationCapped::class]; + } + + public function testFirstPageHasFirstThreeItemsAndNextLink(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdCollectionPaged', $body['@context']); + $this->assertSame('/jsonld_collection_paged', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(30, $body['hydra:totalItems']); + $this->assertCount(3, $body['hydra:member']); + $this->assertSame([1, 2, 3], array_column($body['hydra:member'], 'id')); + $this->assertSame('/jsonld_collection_paged?page=1', $body['hydra:view']['@id']); + $this->assertSame('hydra:PartialCollectionView', $body['hydra:view']['@type']); + $this->assertSame('/jsonld_collection_paged?page=1', $body['hydra:view']['hydra:first']); + $this->assertSame('/jsonld_collection_paged?page=10', $body['hydra:view']['hydra:last']); + $this->assertSame('/jsonld_collection_paged?page=2', $body['hydra:view']['hydra:next']); + } + + public function testMiddlePageHasPreviousAndNext(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?page=7', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['hydra:member']); + $this->assertSame([19, 20, 21], array_column($body['hydra:member'], 'id')); + $this->assertSame('/jsonld_collection_paged?page=6', $body['hydra:view']['hydra:previous']); + $this->assertSame('/jsonld_collection_paged?page=8', $body['hydra:view']['hydra:next']); + } + + public function testLastPageOmitsNext(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?page=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([28, 29, 30], array_column($body['hydra:member'], 'id')); + $this->assertSame('/jsonld_collection_paged?page=9', $body['hydra:view']['hydra:previous']); + $this->assertArrayNotHasKey('hydra:next', $body['hydra:view']); + } + + public function testPaginationDisabledExposesAllItems(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?pagination=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame(30, $body['hydra:totalItems']); + $this->assertCount(30, $body['hydra:member']); + } + + public function testItemsPerPageOverridesDefault(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?page=2&itemsPerPage=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(10, $body['hydra:member']); + $this->assertSame('/jsonld_collection_paged?itemsPerPage=10&page=1', $body['hydra:view']['hydra:first']); + $this->assertSame('/jsonld_collection_paged?itemsPerPage=10&page=3', $body['hydra:view']['hydra:last']); + $this->assertSame('/jsonld_collection_paged?itemsPerPage=10&page=1', $body['hydra:view']['hydra:previous']); + $this->assertSame('/jsonld_collection_paged?itemsPerPage=10&page=3', $body['hydra:view']['hydra:next']); + } + + public function testItemsPerPageZeroReturnsEmptyMember(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?itemsPerPage=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(0, $body['hydra:member']); + } + + public function testFilterExactMatchByIdPreservesViewQueryString(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?id=8', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(1, $body['hydra:member']); + $this->assertSame(8, $body['hydra:member'][0]['id']); + $this->assertSame('/jsonld_collection_paged?id=8', $body['hydra:view']['@id']); + } + + public function testFilterUrlEncodedValuePreservedInView(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?id=%2Fdummies%2F8', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/jsonld_collection_paged?id=%2Fdummies%2F8', $body['hydra:view']['@id']); + } + + public function testFilterByEncodedNameValuePreservedInView(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?name=Dummy%20%238', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(1, $body['hydra:member']); + $this->assertSame(8, $body['hydra:member'][0]['id']); + $this->assertSame('/jsonld_collection_paged?name=Dummy%20%238', $body['hydra:view']['@id']); + } + + public function testEmptyResultExposesEmptyMember(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?id=999', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(0, $body['hydra:totalItems']); + $this->assertCount(0, $body['hydra:member']); + } + + public function testPartialPaginationDropsFirstAndLast(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?page=7&partial=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('hydra:PartialCollectionView', $body['hydra:view']['@type']); + $this->assertArrayNotHasKey('hydra:first', $body['hydra:view']); + $this->assertArrayNotHasKey('hydra:last', $body['hydra:view']); + $this->assertArrayHasKey('hydra:next', $body['hydra:view']); + $this->assertArrayHasKey('hydra:previous', $body['hydra:view']); + } + + public function testCollectionWithoutHydraPrefix(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_no_prefix', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('totalItems', $body); + $this->assertArrayHasKey('member', $body); + $this->assertArrayNotHasKey('hydra:totalItems', $body); + $this->assertArrayNotHasKey('hydra:member', $body); + } + + public function testItemsPerPageZeroAndPageGreaterThanOneReturns400(): void + { + $response = self::createClient()->request('GET', '/jsonld_pagination_capped?itemsPerPage=0&page=2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(400); + $body = $response->toArray(false); + $this->assertSame('Page should not be greater than 1 if limit is equal to 0', $body['detail']); + } + + public function testPaginationMaximumItemsPerPageCapsClientItemsPerPage(): void + { + $response = self::createClient()->request('GET', '/jsonld_pagination_capped?itemsPerPage=40', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(30, $body['hydra:member']); + } +} diff --git a/tests/Functional/JsonLd/HydraDocsTest.php b/tests/Functional/JsonLd/HydraDocsTest.php new file mode 100644 index 00000000000..92bcd766405 --- /dev/null +++ b/tests/Functional/JsonLd/HydraDocsTest.php @@ -0,0 +1,210 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\HydraDocsDeprecated; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\HydraDocsRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\HydraDocsResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HydraDocsTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [HydraDocsResource::class, HydraDocsRelated::class, HydraDocsDeprecated::class]; + } + + public function testDocumentationLinkHeader(): void + { + $response = self::createClient()->request('GET', '/'); + $link = $response->getHeaders()['link'][0] ?? ''; + $this->assertStringContainsString('rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', $link); + } + + public function testApiVocabularyShape(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld'); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + + $this->assertIsArray($body['@context']); + $vocab = $body['@context'][1] ?? null; + $this->assertIsArray($vocab); + $this->assertSame('http://localhost/docs.jsonld#', $vocab['@vocab']); + $this->assertSame(['@id' => 'rdfs:domain', '@type' => '@id'], $vocab['domain']); + $this->assertSame(['@id' => 'rdfs:range', '@type' => '@id'], $vocab['range']); + $this->assertSame(['@id' => 'rdfs:subClassOf', '@type' => '@id'], $vocab['subClassOf']); + + $this->assertSame('/docs.jsonld', $body['@id']); + $this->assertNotEmpty($body['hydra:title']); + $this->assertNotEmpty($body['hydra:description']); + $this->assertSame('/', $body['hydra:entrypoint']); + } + + public function testSupportedClassesIncludeRegisteredAndOmitNonResources(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld'); + $body = $response->toArray(); + $titles = array_column($body['hydra:supportedClass'], 'hydra:title'); + $this->assertContains('Entrypoint', $titles); + $this->assertContains('JsonLdHydraDocs', $titles); + $this->assertContains('JsonLdHydraDocsRelated', $titles); + $this->assertNotContains('UnknownDummy', $titles); + $this->assertNotContains('HydraDocsResource', $titles, 'class FQCN should not leak when shortName is set'); + } + + public function testResourceClassMetadata(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $resource = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocs'); + $this->assertNotNull($resource); + $this->assertSame('#JsonLdHydraDocs', $resource['@id']); + $this->assertSame('hydra:Class', $resource['@type']); + $this->assertSame('JsonLdHydraDocs', $resource['hydra:title']); + $this->assertSame('A docs sample.', $resource['hydra:description']); + } + + public function testSubClassOfFromTypes(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $related = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocsRelated'); + $this->assertNotNull($related); + $this->assertSame('https://schema.org/Product', $related['subClassOf']); + } + + public function testPropertyMetadataReadableWritableRequired(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $resource = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocs'); + $name = $this->findProperty($resource, 'name'); + $this->assertNotNull($name); + $this->assertSame('hydra:SupportedProperty', $name['@type']); + $this->assertTrue($name['hydra:readable']); + $this->assertSame('https://schema.org/name', $name['hydra:property']['@id']); + $this->assertSame('rdf:Property', $name['hydra:property']['@type']); + $this->assertSame('name', $name['hydra:property']['label']); + $this->assertSame('#JsonLdHydraDocs', $name['hydra:property']['domain']); + $this->assertSame('xsd:string', $name['hydra:property']['range']); + $this->assertSame('name', $name['hydra:title']); + $this->assertSame('The doc resource name.', $name['hydra:description']); + } + + public function testRelationPropertyRangeAndCardinality(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $resource = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocs'); + + $related = $this->findProperty($resource, 'related'); + $this->assertNotNull($related); + $this->assertSame('#JsonLdHydraDocsRelated', $related['hydra:property']['range']); + $this->assertSame(1, $related['hydra:property']['owl:maxCardinality']); + + $relateds = $this->findProperty($resource, 'relateds'); + $this->assertNotNull($relateds); + $this->assertSame('#JsonLdHydraDocsRelated', $relateds['hydra:property']['range']); + $this->assertArrayNotHasKey('owl:maxCardinality', $relateds['hydra:property']); + } + + public function testOperationMetadata(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $resource = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocs'); + + $get = $this->findOperation($resource, 'GET'); + $this->assertNotNull($get); + $this->assertContains('hydra:Operation', (array) $get['@type']); + $this->assertContains('schema:FindAction', (array) $get['@type']); + $this->assertSame('GET', $get['hydra:method']); + $this->assertSame('getJsonLdHydraDocs', $get['hydra:title']); + $this->assertSame('Retrieves a JsonLdHydraDocs resource.', $get['hydra:description']); + $this->assertSame('JsonLdHydraDocs', $get['returns']); + + $put = $this->findOperation($resource, 'PUT'); + $this->assertNotNull($put); + $this->assertSame('putJsonLdHydraDocs', $put['hydra:title']); + $this->assertSame('Replaces the JsonLdHydraDocs resource.', $put['hydra:description']); + + $delete = $this->findOperation($resource, 'DELETE'); + $this->assertNotNull($delete); + $this->assertSame('deleteJsonLdHydraDocs', $delete['hydra:title']); + $this->assertSame('Deletes the JsonLdHydraDocs resource.', $delete['hydra:description']); + $this->assertSame('owl:Nothing', $delete['returns']); + } + + public function testDeprecationOnResourceAndProperty(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $deprecated = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocsDeprecated'); + $this->assertNotNull($deprecated); + $this->assertTrue($deprecated['owl:deprecated']); + + $deprecatedField = $this->findProperty($deprecated, 'deprecatedField'); + $this->assertNotNull($deprecatedField); + $this->assertTrue($deprecatedField['hydra:property']['owl:deprecated']); + + $entrypoint = $this->findClass($body['hydra:supportedClass'], 'Entrypoint'); + $this->assertNotNull($entrypoint); + $deprecatedEntrypointProp = $this->findProperty($entrypoint, 'getJsonLdHydraDocsDeprecatedCollection'); + $this->assertNotNull($deprecatedEntrypointProp, 'deprecation on resource must propagate to entrypoint property'); + $this->assertTrue($deprecatedEntrypointProp['owl:deprecated']); + } + + /** + * @param list> $supportedClass + */ + private function findClass(array $supportedClass, string $title): ?array + { + foreach ($supportedClass as $cls) { + if (($cls['hydra:title'] ?? null) === $title) { + return $cls; + } + } + + return null; + } + + /** + * @param array $resource + */ + private function findProperty(array $resource, string $name): ?array + { + foreach ($resource['hydra:supportedProperty'] ?? [] as $prop) { + if (($prop['hydra:title'] ?? null) === $name) { + return $prop; + } + } + + return null; + } + + /** + * @param array $resource + */ + private function findOperation(array $resource, string $method): ?array + { + foreach ($resource['hydra:supportedOperation'] ?? [] as $op) { + if (($op['hydra:method'] ?? null) === $method) { + return $op; + } + } + + return null; + } +} diff --git a/tests/Functional/JsonLd/HydraErrorTest.php b/tests/Functional/JsonLd/HydraErrorTest.php new file mode 100644 index 00000000000..aadfdf41f6e --- /dev/null +++ b/tests/Functional/JsonLd/HydraErrorTest.php @@ -0,0 +1,158 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\HydraErrorResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HydraErrorTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [HydraErrorResource::class]; + } + + public function testBadRequestErrorIsRfc7807AndHydraCompliant(): void + { + $response = self::createClient()->request('POST', '/jsonld_hydra_errors_bad_request', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(400); + $headers = $response->getHeaders(false); + $this->assertSame('application/problem+json; charset=utf-8', $headers['content-type'][0]); + $this->assertStringContainsString( + '; rel="http://www.w3.org/ns/json-ld#error"', + implode(',', $headers['link']), + ); + $body = $response->toArray(false); + $this->assertArrayHasKey('@context', $body); + $this->assertArrayHasKey('type', $body); + $this->assertSame('An error occurred', $body['hydra:title']); + $this->assertArrayHasKey('detail', $body); + $this->assertArrayHasKey('hydra:description', $body); + $this->assertArrayHasKey('trace', $body); + $this->assertArrayHasKey('status', $body); + $this->assertArrayNotHasKey('title', $body); + $this->assertArrayNotHasKey('description', $body); + } + + public function testValidationErrorReturnsConstraintViolationList(): void + { + $response = self::createClient()->request('POST', '/jsonld_hydra_errors_validation', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertSame('application/problem+json; charset=utf-8', $response->getHeaders(false)['content-type'][0]); + $this->assertJsonContains([ + '@context' => '/contexts/ConstraintViolation', + '@id' => '/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', + '@type' => 'ConstraintViolation', + 'status' => 422, + 'violations' => [ + [ + 'propertyPath' => 'name', + 'message' => 'This value should not be blank.', + 'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ], + ], + 'detail' => 'name: This value should not be blank.', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'name: This value should not be blank.', + 'type' => '/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ]); + } + + public function testNotFoundReturnsHydraError(): void + { + $response = self::createClient()->request('POST', '/does_not_exist', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(404); + $headers = $response->getHeaders(false); + $this->assertSame('application/problem+json; charset=utf-8', $headers['content-type'][0]); + $this->assertStringContainsString( + '; rel="http://www.w3.org/ns/json-ld#error"', + implode(',', $headers['link']), + ); + $body = $response->toArray(false); + $this->assertArrayHasKey('@context', $body); + $this->assertArrayHasKey('type', $body); + $this->assertSame('An error occurred', $body['hydra:title']); + $this->assertArrayHasKey('detail', $body); + $this->assertArrayNotHasKey('description', $body); + } + + public function testMethodNotAllowedReturnsHydraError(): void + { + $response = self::createClient()->request('POST', '/jsonld_hydra_errors_patch_only', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(405); + $headers = $response->getHeaders(false); + $this->assertSame('application/problem+json; charset=utf-8', $headers['content-type'][0]); + $this->assertStringContainsString( + '; rel="http://www.w3.org/ns/json-ld#error"', + implode(',', $headers['link']), + ); + $body = $response->toArray(false); + $this->assertArrayHasKey('@context', $body); + $this->assertArrayHasKey('type', $body); + $this->assertSame('An error occurred', $body['hydra:title']); + $this->assertArrayHasKey('detail', $body); + $this->assertArrayNotHasKey('description', $body); + } + + public function testNoHydraPrefixWhenDisabled(): void + { + $response = self::createClient()->request('POST', '/jsonld_hydra_errors_no_prefix', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(400); + $headers = $response->getHeaders(false); + $this->assertSame('application/problem+json; charset=utf-8', $headers['content-type'][0]); + $this->assertStringContainsString( + '; rel="http://www.w3.org/ns/json-ld#error"', + implode(',', $headers['link']), + ); + $body = $response->toArray(false); + $this->assertArrayHasKey('@context', $body); + $this->assertArrayHasKey('type', $body); + $this->assertSame('An error occurred', $body['hydra:title']); + $this->assertArrayHasKey('detail', $body); + $this->assertArrayHasKey('trace', $body); + $this->assertArrayHasKey('status', $body); + $this->assertArrayNotHasKey('description', $body); + } +} diff --git a/tests/Functional/HydraTest.php b/tests/Functional/JsonLd/HydraHideFromDocsTest.php similarity index 88% rename from tests/Functional/HydraTest.php rename to tests/Functional/JsonLd/HydraHideFromDocsTest.php index 5fc068d23dc..c8806f883dd 100644 --- a/tests/Functional/HydraTest.php +++ b/tests/Functional/JsonLd/HydraHideFromDocsTest.php @@ -11,14 +11,14 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Functional; +namespace ApiPlatform\Tests\Functional\JsonLd; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HideHydraClass; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HideHydraOperation; use ApiPlatform\Tests\SetupClassResourcesTrait; -class HydraTest extends ApiTestCase +class HydraHideFromDocsTest extends ApiTestCase { use SetupClassResourcesTrait; @@ -32,10 +32,7 @@ public static function getResources(): array return [HideHydraOperation::class, HideHydraClass::class]; } - /** - * The input DTO denormalizes an existing Doctrine entity. - */ - public function testIssue6465(): void + public function testHideHydraClassAndOperationFromDocsAndEntrypoint(): void { $response = self::createClient()->request('GET', 'docs', [ 'headers' => ['accept' => 'application/ld+json'], diff --git a/tests/Functional/JsonLd/InheritanceIriTest.php b/tests/Functional/JsonLd/InheritanceIriTest.php new file mode 100644 index 00000000000..1e1e415d610 --- /dev/null +++ b/tests/Functional/JsonLd/InheritanceIriTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5438\Contractor; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5438\Employee; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5438\Person; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InheritanceIriTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Person::class, Contractor::class, Employee::class]; + } + + public function testCollectionItemsUseConcreteSubtypeIris(): void + { + $response = self::createClient()->request('GET', '/people_5438', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/People5438', $body['@context']); + $this->assertSame('/people_5438', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(2, $body['hydra:totalItems']); + + $this->assertSame([ + [ + '@id' => '/contractor_5438/1', + '@type' => 'Contractor', + 'id' => 1, + 'name' => 'a', + ], + [ + '@id' => '/employee_5438/2', + '@type' => 'Employee', + 'id' => 2, + 'name' => 'b', + ], + ], $body['hydra:member']); + } +} diff --git a/tests/Functional/JsonLd/InitializeInputTest.php b/tests/Functional/JsonLd/InitializeInputTest.php new file mode 100644 index 00000000000..50f3b374a8d --- /dev/null +++ b/tests/Functional/JsonLd/InitializeInputTest.php @@ -0,0 +1,65 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InitializeInput; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InitializeInputTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [InitializeInput::class]; + } + + public function testPutPreservesManagerFromPreviousData(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([InitializeInput::class]); + + $manager = $this->getManager(); + $entity = new InitializeInput(); + $entity->id = 1; + $entity->manager = 'Orwell'; + $entity->name = '1984'; + $manager->persist($entity); + $manager->flush(); + + $response = self::createClient()->request('PUT', '/initialize_inputs/1', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['name' => 'La peste'], + ]); + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('/contexts/InitializeInput', $body['@context']); + $this->assertSame('/initialize_inputs/1', $body['@id']); + $this->assertSame('InitializeInput', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame('Orwell', $body['manager']); + $this->assertSame('La peste', $body['name']); + } +} diff --git a/tests/Functional/JsonLd/InputDtoIriDenormalizationTest.php b/tests/Functional/JsonLd/InputDtoIriDenormalizationTest.php new file mode 100644 index 00000000000..4d31a832a83 --- /dev/null +++ b/tests/Functional/JsonLd/InputDtoIriDenormalizationTest.php @@ -0,0 +1,69 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Bar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Foo; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class InputDtoIriDenormalizationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Foo::class, Bar::class]; + } + + protected function setUp(): void + { + self::bootKernel(); + + if ($this->isMongoDB()) { + $this->markTestSkipped('This test uses Doctrine ORM entities without MongoDB equivalents.'); + } + + $this->recreateSchema([Foo::class, Bar::class]); + + $manager = $this->getManager(); + $foo = new Foo(); + $foo->title = 'Foo'; + $manager->persist($foo); + $bar = new Bar(); + $bar->title = 'Bar one'; + $manager->persist($bar); + $bar2 = new Bar(); + $bar2->title = 'Bar two'; + $manager->persist($bar2); + $manager->flush(); + } + + public function testInputDtoDenormalizesEntityFromIri(): void + { + $response = self::createClient()->request('POST', '/foo/1/validate', [ + 'json' => ['bar' => '/bar6465s/2'], + ]); + + $res = $response->toArray(); + $this->assertEquals('Bar two', $res['title']); + } +} diff --git a/tests/Functional/JsonLd/InputOutputDtoTest.php b/tests/Functional/JsonLd/InputOutputDtoTest.php new file mode 100644 index 00000000000..e52474d0f82 --- /dev/null +++ b/tests/Functional/JsonLd/InputOutputDtoTest.php @@ -0,0 +1,277 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\CustomInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\CustomOutputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DummyCollectionDto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DummyFooCollectionDto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DummyIdCollectionDto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InputOutputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NoInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PostNoOutputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UserResource; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputDtoTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + CustomInputResource::class, + CustomOutputResource::class, + InputOutputResource::class, + NoInputResource::class, + PostNoOutputResource::class, + DummyCollectionDto::class, + DummyFooCollectionDto::class, + DummyIdCollectionDto::class, + UserResource::class, + ]; + } + + public function testCreateResourceWithCustomInput(): void + { + $response = self::createClient()->request('POST', '/jsonld_custom_inputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['foo' => 'test', 'bar' => 1], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $this->assertJsonContains([ + '@context' => '/contexts/JsonLdCustomInput', + '@id' => '/jsonld_custom_inputs/1', + '@type' => 'JsonLdCustomInput', + 'lorem' => 'test', + 'ipsum' => '1', + 'id' => 1, + ]); + } + + public function testCustomInputRejectsBadType(): void + { + $response = self::createClient()->request('POST', '/jsonld_custom_inputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['foo' => 'test', 'bar' => 'not-an-int'], + ]); + $this->assertResponseStatusCodeSame(400); + $body = $response->toArray(false); + $this->assertSame('The input data is misformatted.', $body['detail']); + } + + public function testItemWithCustomOutput(): void + { + $response = self::createClient()->request('GET', '/jsonld_custom_outputs/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('CustomOutputDto', $body['@type']); + $this->assertSame('test', $body['foo']); + $this->assertSame(1, $body['bar']); + $this->assertArrayHasKey('@context', $body); + } + + public function testCollectionWithCustomOutput(): void + { + $response = self::createClient()->request('GET', '/jsonld_custom_outputs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdCustomOutput', $body['@context']); + $this->assertSame('/jsonld_custom_outputs', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(2, $body['hydra:totalItems']); + $this->assertCount(2, $body['hydra:member']); + $this->assertSame('CustomOutputDto', $body['hydra:member'][0]['@type']); + } + + public function testPostWithoutOutputReturns204(): void + { + $response = self::createClient()->request('POST', '/jsonld_post_no_output', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['lorem' => 'a', 'ipsum' => 'b'], + ]); + $this->assertResponseStatusCodeSame(204); + $this->assertEmpty($response->getContent()); + } + + public function testInputOutputCycle(): void + { + $response = self::createClient()->request('POST', '/jsonld_input_outputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['foo' => 'test', 'bar' => 1], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('InputOutputDto', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame(1, $body['baz']); + $this->assertSame('test', $body['bat']); + $this->assertSame([], $body['relatedDummies']); + + $response = self::createClient()->request('PUT', '/jsonld_input_outputs/1', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['foo' => 'test', 'bar' => 2], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('InputOutputDto', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame(2, $body['baz']); + $this->assertSame('test', $body['bat']); + } + + public function testCreateNoInputResource(): void + { + if ($_SERVER['USE_SYMFONY_LISTENERS'] ?? false) { + $this->markTestSkipped('PlaceholderAction cannot resolve $data when input:false in event-listener mode.'); + } + + $response = self::createClient()->request('POST', '/jsonld_no_inputs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('JsonLdNoInput', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame(1, $body['baz']); + $this->assertSame('test', $body['bat']); + } + + public function testUpdateNoInputResource(): void + { + $response = self::createClient()->request('POST', '/jsonld_no_inputs/1/double_bat', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('JsonLdNoInput', $body['@type']); + $this->assertSame('testtest', $body['bat']); + } + + public function testCollectionWithCustomOutputAndNoIdentifierUsesGenid(): void + { + $response = self::createClient()->request('GET', '/jsonld_dummy_collection_dtos', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdDummyCollectionDto', $body['@context']); + $this->assertSame('/jsonld_dummy_collection_dtos', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertCount(2, $body['hydra:member']); + $this->assertSame(2, $body['hydra:totalItems']); + foreach ($body['hydra:member'] as $member) { + $this->assertStringStartsWith('/.well-known/genid/', $member['@id']); + $this->assertSame('DummyCollectionDtoOutput', $member['@type']); + $this->assertSame('foo', $member['foo']); + $this->assertIsInt($member['bar']); + } + } + + public function testCollectionWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('GET', '/jsonld_dummy_foo_collection_dtos', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdDummyFooCollectionDto', $body['@context']); + $this->assertSame('/jsonld_dummy_foo_collection_dtos', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertCount(2, $body['hydra:member']); + foreach ($body['hydra:member'] as $member) { + $this->assertStringContainsString('/jsonld_dummy_foos/bar', $member['@id']); + $this->assertSame('JsonLdDummyFooCollectionDto', $member['@type']); + } + } + + public function testCollectionWithCustomOutputResourceWithIdentifierUsesGenid(): void + { + $response = self::createClient()->request('GET', '/jsonld_dummy_id_collection_dtos', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdDummyIdCollectionDto', $body['@context']); + $this->assertCount(2, $body['hydra:member']); + foreach ($body['hydra:member'] as $member) { + $this->assertStringStartsWith('/.well-known/genid/', $member['@id']); + $this->assertSame('DummyIdCollectionDtoOutput', $member['@type']); + $this->assertArrayHasKey('id', $member); + $this->assertArrayHasKey('foo', $member); + $this->assertArrayHasKey('bar', $member); + } + } + + public function testResetPasswordViaInputDto(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('POST', '/user-reset-password', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['email' => 'user@example.com'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('user@example.com', $body['email']); + } + + public function testResetPasswordWithInvalidEmailReturns422(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('POST', '/user-reset-password', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['email' => 'this is not an email'], + ]); + $this->assertResponseStatusCodeSame(422); + } +} diff --git a/tests/Functional/JsonLd/InterfaceAsResourceTest.php b/tests/Functional/JsonLd/InterfaceAsResourceTest.php new file mode 100644 index 00000000000..2875224c575 --- /dev/null +++ b/tests/Functional/JsonLd/InterfaceAsResourceTest.php @@ -0,0 +1,60 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InterfaceTaxon; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InterfaceTaxonProduct; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InterfaceAsResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [InterfaceTaxon::class, InterfaceTaxonProduct::class]; + } + + public function testRetrieveTaxonViaInterface(): void + { + $response = self::createClient()->request('GET', '/jsonld_interface_taxa/WONDERFUL_TAXON', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertSame([ + '@context' => '/contexts/JsonLdInterfaceTaxon', + '@id' => '/jsonld_interface_taxa/WONDERFUL_TAXON', + '@type' => 'JsonLdInterfaceTaxon', + 'code' => 'WONDERFUL_TAXON', + ], $response->toArray()); + } + + public function testRetrieveProductWithMainTaxonReferencesInterfaceResource(): void + { + $response = self::createClient()->request('GET', '/jsonld_interface_taxon_products/GREAT_PRODUCT', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('JsonLdInterfaceTaxonProduct', $body['@type']); + $this->assertSame('GREAT_PRODUCT', $body['code']); + $this->assertIsArray($body['mainTaxon']); + $this->assertSame('/jsonld_interface_taxa/WONDERFUL_TAXON', $body['mainTaxon']['@id']); + $this->assertSame('JsonLdInterfaceTaxon', $body['mainTaxon']['@type']); + $this->assertSame('WONDERFUL_TAXON', $body['mainTaxon']['code']); + } +} diff --git a/tests/Functional/JsonLd/InterfaceDtoOutputTest.php b/tests/Functional/JsonLd/InterfaceDtoOutputTest.php new file mode 100644 index 00000000000..b34cb9a602d --- /dev/null +++ b/tests/Functional/JsonLd/InterfaceDtoOutputTest.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\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InterfaceDtoOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InterfaceDtoOutputTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [InterfaceDtoOutputResource::class]; + } + + public function testCollectionExposesOnlyInterfaceProperties(): void + { + $response = self::createClient()->request('GET', '/jsonld_interface_dto_outputs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $member = $body['hydra:member'] ?? $body['member']; + $this->assertArrayHasKey('@id', $member[0]); + $this->assertArrayHasKey('@type', $member[0]); + $this->assertArrayHasKey('name', $member[0]); + $this->assertArrayNotHasKey('city', $member[0]); + } +} diff --git a/tests/Functional/JsonLd/IriOnlyTest.php b/tests/Functional/JsonLd/IriOnlyTest.php new file mode 100644 index 00000000000..723d24b3d16 --- /dev/null +++ b/tests/Functional/JsonLd/IriOnlyTest.php @@ -0,0 +1,80 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\IriOnlyResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class IriOnlyTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [IriOnlyResource::class]; + } + + #[DataProvider('contextUris')] + public function testContextEndpointReturnsIriOnlyContext(string $uri): void + { + $response = self::createClient()->request('GET', $uri); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $this->assertSame([ + '@context' => [ + '@vocab' => 'http://localhost/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => ['@type' => '@id'], + ], + ], $response->toArray()); + } + + public static function contextUris(): array + { + return [ + ['/contexts/JsonLdIriOnlyResource'], + ['/contexts/JsonLdIriOnlyResource.jsonld'], + ]; + } + + public function testContextEndpointWithJsonExtensionReturns404(): void + { + self::createClient()->request('GET', '/contexts/JsonLdIriOnlyResource.json'); + $this->assertResponseStatusCodeSame(404); + } + + public function testCollectionReturnsIriOnlyMembers(): void + { + $response = self::createClient()->request('GET', '/jsonld_iri_only_resources'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([ + '@vocab' => 'http://localhost/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => ['@type' => '@id'], + ], $body['@context']); + $this->assertSame('/jsonld_iri_only_resources', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame([ + '/jsonld_iri_only_resources/1', + '/jsonld_iri_only_resources/2', + '/jsonld_iri_only_resources/3', + ], $body['hydra:member']); + $this->assertSame(3, $body['hydra:totalItems']); + } +} diff --git a/tests/Functional/JsonLd/ItemUriTemplateCollectionTest.php b/tests/Functional/JsonLd/ItemUriTemplateCollectionTest.php new file mode 100644 index 00000000000..0f6f4df5ae1 --- /dev/null +++ b/tests/Functional/JsonLd/ItemUriTemplateCollectionTest.php @@ -0,0 +1,112 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\Recipe; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\RecipeCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Recipe as EntityRecipe; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class ItemUriTemplateCollectionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Recipe::class, RecipeCollection::class]; + } + + public function testCollectionItemsExposeItemUriTemplateAsId(): void + { + self::createClient()->request('GET', '/item_uri_template_recipes'); + $this->assertResponseIsSuccessful(); + + $this->assertJsonContains([ + 'member' => [ + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes/1', + 'name' => 'Dummy Recipe', + ], + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes/2', + 'name' => 'Dummy Recipe 2', + ], + ], + ]); + } + + public function testItemUriTemplateAppliesWhenSourceIsStateOption(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([EntityRecipe::class]); + + $manager = $this->getManager(); + for ($i = 0; $i < 10; ++$i) { + $recipe = new EntityRecipe(); + $recipe->name = "Recipe $i"; + $recipe->description = "Description of recipe $i"; + $recipe->author = "Author $i"; + $recipe->recipeIngredient = [ + "Ingredient 1 for recipe $i", + "Ingredient 2 for recipe $i", + ]; + $recipe->recipeInstructions = "Instructions for recipe $i"; + $recipe->prepTime = '10 minutes'; + $recipe->cookTime = '20 minutes'; + $recipe->totalTime = '30 minutes'; + $recipe->recipeCategory = "Category $i"; + $recipe->recipeCuisine = "Cuisine $i"; + $recipe->suitableForDiet = "Diet $i"; + + $manager->persist($recipe); + } + $manager->flush(); + + self::createClient()->request('GET', '/item_uri_template_recipes_state_option'); + $this->assertResponseIsSuccessful(); + + $this->assertJsonContains([ + 'member' => [ + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes_state_option/1', + 'name' => 'Recipe 0', + ], + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes_state_option/2', + 'name' => 'Recipe 1', + ], + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes_state_option/3', + 'name' => 'Recipe 2', + ], + ], + ]); + } +} diff --git a/tests/Functional/JsonLd/ItemUriTemplateHydraTest.php b/tests/Functional/JsonLd/ItemUriTemplateHydraTest.php new file mode 100644 index 00000000000..3646a55397e --- /dev/null +++ b/tests/Functional/JsonLd/ItemUriTemplateHydraTest.php @@ -0,0 +1,176 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\UriTemplateCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CollectionReferencingItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5662\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5662\Review; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ItemReferencedInCollection; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ItemUriTemplateHydraTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + UriTemplateCar::class, + CollectionReferencingItem::class, + ItemReferencedInCollection::class, + Book::class, + Review::class, + ]; + } + + public function testGetCollectionDerivesItemIriFromFirstGetOperation(): void + { + $response = self::createClient()->request('GET', '/jsonld_uri_template_cars', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdUriTemplateCar', $body['@context']); + $this->assertSame('/jsonld_uri_template_cars', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertCount(2, $body['hydra:member']); + foreach ($body['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/jsonld_uri_template_cars/.+$#', $member['@id']); + $this->assertSame('JsonLdUriTemplateCar', $member['@type']); + } + } + + public function testGetCollectionWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('GET', '/jsonld_uri_template_brands/renault/cars', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/jsonld_uri_template_brands/renault/cars', $body['@id']); + foreach ($body['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/jsonld_uri_template_brands/renault/cars/.+$#', $member['@id']); + } + } + + public function testPostWithoutItemUriTemplateUsesFirstGetOperation(): void + { + $response = self::createClient()->request('POST', '/jsonld_uri_template_cars', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['owner' => 'Vincent'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertMatchesRegularExpression('#^/jsonld_uri_template_cars/.+$#', $body['@id']); + $this->assertSame('JsonLdUriTemplateCar', $body['@type']); + } + + public function testPostWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('POST', '/jsonld_uri_template_brands/renault/cars', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['owner' => 'Vincent'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertMatchesRegularExpression('#^/jsonld_uri_template_brands/renault/cars/.+$#', $body['@id']); + } + + public function testCollectionReferencingAnotherResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/item_referenced_in_collection', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/CollectionReferencingItem', + '@id' => '/item_referenced_in_collection', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + ['@id' => '/item_referenced_in_collection/a', '@type' => 'ItemReferencedInCollection', 'id' => 'a', 'name' => 'hello'], + ['@id' => '/item_referenced_in_collection/b', '@type' => 'ItemReferencedInCollection', 'id' => 'b', 'name' => 'you'], + ], + 'hydra:totalItems' => 2, + ]); + } + + public function testCollectionReferencingItemUriTemplate(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/issue5662/books/a/reviews', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/Review', $body['@context']); + $this->assertSame('/issue5662/books/a/reviews', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(2, $body['hydra:totalItems']); + $this->assertSame('/issue5662/books/a/reviews/1', $body['hydra:member'][0]['@id']); + $this->assertSame('/issue5662/books/b/reviews/2', $body['hydra:member'][1]['@id']); + } + + public function testCollectionReferencingInvalidItemUriTemplateFallsBackToCollectionUri(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/issue5662/admin/reviews', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/issue5662/admin/reviews', $body['@id']); + $this->assertSame('/issue5662/admin/reviews/1', $body['hydra:member'][0]['@id']); + $this->assertSame('/issue5662/admin/reviews/2', $body['hydra:member'][1]['@id']); + } + + public function testPostWithItemUriTemplateGeneratesIriFromTemplate(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('POST', '/issue5662/books/a/reviews', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['body' => 'Good book'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/issue5662/books/a/reviews/0', $body['@id']); + } +} diff --git a/tests/Functional/ItemUriTemplateTest.php b/tests/Functional/JsonLd/ItemUriTemplateNotFoundTest.php similarity index 84% rename from tests/Functional/ItemUriTemplateTest.php rename to tests/Functional/JsonLd/ItemUriTemplateNotFoundTest.php index ac4a6ea563f..b40907860c1 100644 --- a/tests/Functional/ItemUriTemplateTest.php +++ b/tests/Functional/JsonLd/ItemUriTemplateNotFoundTest.php @@ -11,13 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Functional; +namespace ApiPlatform\Tests\Functional\JsonLd; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6718\Organization; use ApiPlatform\Tests\SetupClassResourcesTrait; -class ItemUriTemplateTest extends ApiTestCase +class ItemUriTemplateNotFoundTest extends ApiTestCase { use SetupClassResourcesTrait; @@ -31,7 +31,7 @@ public static function getResources(): array return [Organization::class]; } - public function testIssue6718(): void + public function testNotFoundOnInvalidItemUriTemplateRelation(): void { self::createClient()->request('GET', '/6718_users/1/organisation', [ 'headers' => ['accept' => 'application/ld+json'], diff --git a/tests/Functional/JsonLd/JsonSerializableTest.php b/tests/Functional/JsonLd/JsonSerializableTest.php new file mode 100644 index 00000000000..f859f9a562a --- /dev/null +++ b/tests/Functional/JsonLd/JsonSerializableTest.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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonSerializableResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class JsonSerializableTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [JsonSerializableResource::class]; + } + + public function testCreateJsonSerializableResource(): void + { + $response = self::createClient()->request('POST', '/jsonld_json_serializables', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'contentType' => 'homepage', + 'fieldValues' => ['title' => 'Sample title'], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSame([ + '@context' => '/contexts/JsonLdJsonSerializable', + '@id' => '/jsonld_json_serializables/1', + '@type' => 'JsonLdJsonSerializable', + 'id' => 1, + 'contentType' => 'homepage', + 'fieldValues' => ['title' => 'Sample title'], + 'status' => ['key' => 'DRAFT', 'value' => 'draft'], + ], $response->toArray()); + } + + public function testGetJsonSerializableResource(): void + { + $response = self::createClient()->request('GET', '/jsonld_json_serializables/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdJsonSerializable', $body['@context']); + $this->assertSame('/jsonld_json_serializables/1', $body['@id']); + $this->assertSame('JsonLdJsonSerializable', $body['@type']); + $this->assertSame(['key' => 'DRAFT', 'value' => 'draft'], $body['status']); + } +} diff --git a/tests/Functional/LinkedDataPlatformTest.php b/tests/Functional/JsonLd/LinkedDataPlatformTest.php similarity index 98% rename from tests/Functional/LinkedDataPlatformTest.php rename to tests/Functional/JsonLd/LinkedDataPlatformTest.php index 7a05b2cfddb..5ad112b61c3 100644 --- a/tests/Functional/LinkedDataPlatformTest.php +++ b/tests/Functional/JsonLd/LinkedDataPlatformTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Functional; +namespace ApiPlatform\Tests\Functional\JsonLd; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyGetPostDeleteOperation; diff --git a/tests/Functional/JsonLd/MaxDepthTest.php b/tests/Functional/JsonLd/MaxDepthTest.php new file mode 100644 index 00000000000..c5b0501475e --- /dev/null +++ b/tests/Functional/JsonLd/MaxDepthTest.php @@ -0,0 +1,69 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\MaxDepthResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MaxDepthTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [MaxDepthResource::class]; + } + + public function testFirstLevelChildIsExposed(): void + { + $response = self::createClient()->request('POST', '/jsonld_max_depth_resources', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'name' => 'level 1', + 'child' => ['name' => 'level 2'], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertArrayHasKey('child', $body); + $this->assertSame('level 2', $body['child']['name']); + } + + public function testSecondLevelChildIsTruncatedByMaxDepth(): void + { + $response = self::createClient()->request('POST', '/jsonld_max_depth_resources', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'name' => 'level 1', + 'child' => [ + 'name' => 'level 2', + 'child' => ['name' => 'level 3'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('level 2', $body['child']['name']); + $this->assertArrayNotHasKey('child', $body['child']); + } +} diff --git a/tests/Functional/JsonLd/MessengerTest.php b/tests/Functional/JsonLd/MessengerTest.php new file mode 100644 index 00000000000..0a3f0c7e560 --- /dev/null +++ b/tests/Functional/JsonLd/MessengerTest.php @@ -0,0 +1,73 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MessengerWithInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MessengerWithResponse; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MessengerTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [MessengerWithInput::class, MessengerWithResponse::class]; + } + + public function testPostMessengerWithSynchronousResultReturnsLdPayload(): void + { + $response = self::createClient()->request('POST', '/messenger_with_inputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['var' => 'test'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/contexts/MessengerWithInput', $body['@context']); + $this->assertSame('/messenger_with_inputs/1', $body['@id']); + $this->assertSame('MessengerWithInput', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame('test', $body['name']); + } + + public function testPostMessengerWithResponseHandlerReturnsRawResponse(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('POST', '/messenger_with_responses', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['var' => 'test'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + $this->assertSame(['data' => 123], json_decode($response->getContent(), true)); + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/JsonLd/MultiResourceContextTest.php b/tests/Functional/JsonLd/MultiResourceContextTest.php new file mode 100644 index 00000000000..ed729866cb2 --- /dev/null +++ b/tests/Functional/JsonLd/MultiResourceContextTest.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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiResourceEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class MultiResourceContextTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MultiResourceEntity::class]; + } + + protected function setUp(): void + { + self::bootKernel(); + + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([MultiResourceEntity::class]); + + $manager = $this->getManager(); + $multi = new MultiResourceEntity(); + $multi->title = 'Multi Resource'; + $manager->persist($multi); + $manager->flush(); + } + + public function testContextUsesShortNameForCurrentResourceVariant(): void + { + $response = self::createClient()->request('GET', '/multi_resources'); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/MultiResource', + ]); + + $response = self::createClient()->request('GET', '/admin/multi_resources'); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/AdminMultiResource', + ]); + } +} diff --git a/tests/Functional/JsonLd/NetworkPathTest.php b/tests/Functional/JsonLd/NetworkPathTest.php new file mode 100644 index 00000000000..b8b946115ec --- /dev/null +++ b/tests/Functional/JsonLd/NetworkPathTest.php @@ -0,0 +1,104 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NetworkPathParent; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NetworkPathResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NetworkPathTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NetworkPathResource::class, NetworkPathParent::class]; + } + + public function testCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_network_path_children', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/contexts/JsonLdNetworkPathChild', $body['@context']); + $this->assertSame('//example.com/jsonld_network_path_children', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame('//example.com/jsonld_network_path_children/1', $body['hydra:member'][0]['@id']); + } + + public function testItemUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_network_path_children/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/contexts/JsonLdNetworkPathChild', $body['@context']); + $this->assertSame('//example.com/jsonld_network_path_children/1', $body['@id']); + $this->assertSame('JsonLdNetworkPathChild', $body['@type']); + $this->assertSame('//example.com/jsonld_network_path_parents/1', $body['parent']); + } + + public function testPostReturnsNetworkPath(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonld_network_path_parents', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonld_network_path_parents/2', $body['@id']); + } + + public function testPostAcceptsNetworkPathInPayload(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonld_network_path_children', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['parent' => '//example.com/jsonld_network_path_parents/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonld_network_path_children/2', $body['@id']); + $this->assertSame('JsonLdNetworkPathChild', $body['@type']); + $this->assertSame('//example.com/jsonld_network_path_parents/1', $body['parent']); + } + + public function testSubresourceCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_network_path_parents/1/children', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/contexts/JsonLdNetworkPathChild', $body['@context']); + $this->assertSame('//example.com/jsonld_network_path_parents/1/children', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + } +} diff --git a/tests/Functional/JsonLd/NoOutputTest.php b/tests/Functional/JsonLd/NoOutputTest.php new file mode 100644 index 00000000000..c9bff3750e8 --- /dev/null +++ b/tests/Functional/JsonLd/NoOutputTest.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\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NoOutputMessage; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NoOutputTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NoOutputMessage::class]; + } + + public function testPostWithOutputFalseReturns202AndEmptyBody(): void + { + $response = self::createClient()->request('POST', '/jsonld_no_output_messages', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(202); + $this->assertEmpty($response->getContent()); + } +} diff --git a/tests/Functional/JsonLd/NonResourceTest.php b/tests/Functional/JsonLd/NonResourceTest.php new file mode 100644 index 00000000000..941a1065e9e --- /dev/null +++ b/tests/Functional/JsonLd/NonResourceTest.php @@ -0,0 +1,152 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DateTimeOnlyResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\GenIdFalseProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NonRelationResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NonResourceContainer; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PlainObjectResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NonResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + NonResourceContainer::class, + NonRelationResource::class, + PlainObjectResource::class, + GenIdFalseProperty::class, + DateTimeOnlyResource::class, + ]; + } + + public function testNonResourceObjectHasGenidAndType(): void + { + $response = self::createClient()->request('GET', '/jsonld_non_resource_containers/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/JsonLdNonResourceContainer', + '@id' => '/jsonld_non_resource_containers/1', + '@type' => 'JsonLdNonResourceContainer', + 'id' => '1', + 'nested' => [ + '@id' => '/jsonld_non_resource_containers/1-nested', + '@type' => 'JsonLdNonResourceContainer', + 'id' => '1-nested', + 'notAResource' => [ + '@type' => 'NonResourceClass', + 'foo' => 'f2', + 'bar' => 'b2', + ], + ], + 'notAResource' => [ + '@type' => 'NonResourceClass', + 'foo' => 'f1', + 'bar' => 'b1', + ], + ]); + $body = $response->toArray(); + $this->assertArrayHasKey('@id', $body['notAResource']); + $this->assertStringStartsWith('/.well-known/genid/', $body['notAResource']['@id']); + } + + public function testCreateResourceWithNonResourceRelation(): void + { + $response = self::createClient()->request('POST', '/jsonld_non_relation_resources', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['relation' => ['foo' => 'test']], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/JsonLdNonRelationResource', + '@id' => '/jsonld_non_relation_resources/1', + '@type' => 'JsonLdNonRelationResource', + 'relation' => [ + '@type' => 'NonRelationPayload', + 'foo' => 'test', + ], + 'id' => 1, + ]); + } + + public function testCreateResourceWithStdClass(): void + { + $response = self::createClient()->request('POST', '/jsonld_plain_object_resources', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'content' => '{"emptyObject":{},"showCaption":false,"alternativeContent":false,"blockLayout":"default"}', + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/jsonld_plain_object_resources/1', $body['@id']); + $this->assertSame('JsonLdPlainObjectResource', $body['@type']); + $this->assertSame([], $body['data']['emptyObject']); + $this->assertFalse($body['data']['showCaption']); + $this->assertFalse($body['data']['alternativeContent']); + $this->assertSame('default', $body['data']['blockLayout']); + } + + public function testGenIdFalsePropertyOmitsAtId(): void + { + $response = self::createClient()->request('GET', '/jsonld_genid_false_properties/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayNotHasKey('@id', $body['totalPrice']); + } + + public function testResourceWithDateTimeProperty(): void + { + $response = self::createClient()->request('GET', '/jsonld_datetime_resources/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('start', $body); + $this->assertNotEmpty($body['start']); + } + + public function testSparseFieldsetOnNonResourceObject(): void + { + $response = self::createClient()->request( + 'GET', + '/jsonld_non_resource_containers/1?properties[]=id&properties[nested][notAResource][]=foo&properties[notAResource][]=bar', + ['headers' => ['Accept' => 'application/ld+json']], + ); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('1', $body['id']); + $this->assertSame('f2', $body['nested']['notAResource']['foo']); + $this->assertSame('b1', $body['notAResource']['bar']); + $this->assertArrayNotHasKey('bar', $body['nested']['notAResource']); + $this->assertArrayNotHasKey('foo', $body['notAResource']); + } +} diff --git a/tests/Functional/JsonLd/PolymorphicResourceCollectionTest.php b/tests/Functional/JsonLd/PolymorphicResourceCollectionTest.php new file mode 100644 index 00000000000..d1f28bb4437 --- /dev/null +++ b/tests/Functional/JsonLd/PolymorphicResourceCollectionTest.php @@ -0,0 +1,55 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\ImageModuleResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\PageResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\TitleModuleResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class PolymorphicResourceCollectionTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [PageResource::class, TitleModuleResource::class, ImageModuleResource::class]; + } + + public function testPolymorphicCollectionPropertyExposesPerItemTypes(): void + { + self::createClient()->request('GET', '/page_resources/page-1'); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'modules' => [ + [ + '@type' => 'TitleModuleResource', + 'id' => 'title-module-1', + 'title' => 'My Title', + ], + [ + '@type' => 'ImageModule', + 'id' => 'image-module-1', + 'url' => 'http://example.com/image.jpg', + ], + ], + ]); + } +} diff --git a/tests/Functional/JsonLd/PropertyCollectionIriOnlyTest.php b/tests/Functional/JsonLd/PropertyCollectionIriOnlyTest.php new file mode 100644 index 00000000000..ec5f3dce0e7 --- /dev/null +++ b/tests/Functional/JsonLd/PropertyCollectionIriOnlyTest.php @@ -0,0 +1,98 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelationSecondLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PropertyCollectionIriOnlyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]; + } + + public function testPropertyUriTemplatesRenderAsIris(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]); + + $manager = $this->getManager(); + $rel1 = new PropertyCollectionIriOnlyRelation(); + $rel1->name = 'asb1'; + $rel2 = new PropertyCollectionIriOnlyRelation(); + $rel2->name = 'asb2'; + $toOne = new PropertyUriTemplateOneToOneRelation(); + $toOne->name = 'xarguš'; + $parent = new PropertyCollectionIriOnly(); + $parent->addPropertyCollectionIriOnlyRelation($rel1); + $parent->addPropertyCollectionIriOnlyRelation($rel2); + $parent->setToOneRelation($toOne); + $manager->persist($parent); + $manager->persist($rel1); + $manager->persist($rel2); + $manager->persist($toOne); + $manager->flush(); + + $response = self::createClient()->request('GET', '/property_collection_iri_onlies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'hydra:member' => [[ + '@id' => '/property_collection_iri_onlies/1', + '@type' => 'PropertyCollectionIriOnly', + 'propertyCollectionIriOnlyRelation' => '/property-collection-relations', + 'iterableIri' => '/parent/1/another-collection-operations', + 'toOneRelation' => '/parent/1/property-uri-template/one-to-ones/1', + ]], + ]); + + $response = self::createClient()->request('GET', '/property_collection_iri_onlies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/PropertyCollectionIriOnly', + '@id' => '/property_collection_iri_onlies/1', + '@type' => 'PropertyCollectionIriOnly', + 'propertyCollectionIriOnlyRelation' => '/property-collection-relations', + 'iterableIri' => '/parent/1/another-collection-operations', + 'toOneRelation' => '/parent/1/property-uri-template/one-to-ones/1', + ]); + } +} diff --git a/tests/Functional/JsonLd/RenamedGetterSetterTest.php b/tests/Functional/JsonLd/RenamedGetterSetterTest.php new file mode 100644 index 00000000000..1f08a5818cb --- /dev/null +++ b/tests/Functional/JsonLd/RenamedGetterSetterTest.php @@ -0,0 +1,49 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\RenamedGetterSetter; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class RenamedGetterSetterTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RenamedGetterSetter::class]; + } + + public function testPostExposesRenamedField(): void + { + $response = self::createClient()->request('POST', '/json_ld_renamed_getter_setters', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['firstnameOnly' => 'Sarah'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertSame([ + '@context' => '/contexts/JsonLdRenamedGetterSetter', + '@id' => '/json_ld_renamed_getter_setters', + '@type' => 'JsonLdRenamedGetterSetter', + 'firstnameOnly' => 'Sarah', + ], $response->toArray()); + } +} diff --git a/tests/Functional/JsonLdTest.php b/tests/Functional/JsonLdTest.php deleted file mode 100644 index d72176f5856..00000000000 --- a/tests/Functional/JsonLdTest.php +++ /dev/null @@ -1,283 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Functional; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\AggregateRating; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\GenIdFalse; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\LevelFirst; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\LevelThird; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6810\JsonLdContextOutput; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\ImageModuleResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\PageResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\TitleModuleResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\Recipe; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\RecipeCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Bar; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Foo; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiResourceEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Recipe as EntityRecipe; -use ApiPlatform\Tests\RecreateSchemaTrait; -use ApiPlatform\Tests\SetupClassResourcesTrait; - -class JsonLdTest extends ApiTestCase -{ - use RecreateSchemaTrait; - use SetupClassResourcesTrait; - - protected static ?bool $alwaysBootKernel = false; - - /** - * @return class-string[] - */ - public static function getResources(): array - { - return [ - Foo::class, - Bar::class, - JsonLdContextOutput::class, - GenIdFalse::class, - AggregateRating::class, - LevelFirst::class, - LevelThird::class, - PageResource::class, - TitleModuleResource::class, - ImageModuleResource::class, - Recipe::class, - RecipeCollection::class, - MultiResourceEntity::class, - ]; - } - - /** - * The input DTO denormalizes an existing Doctrine entity. - */ - public function testIssue6465(): void - { - $container = static::getContainer(); - if ('mongodb' === $container->getParameter('kernel.environment')) { - $this->markTestSkipped(); - } - - $response = self::createClient()->request('POST', '/foo/1/validate', [ - 'json' => ['bar' => '/bar6465s/2'], - ]); - - $res = $response->toArray(); - $this->assertEquals('Bar two', $res['title']); - } - - public function testContextWithOutput(): void - { - $response = self::createClient()->request( - 'GET', - '/json_ld_context_output', - ); - $res = $response->toArray(); - $this->assertEquals($res['@context'], [ - '@vocab' => 'http://localhost/docs.jsonld#', - 'hydra' => 'http://www.w3.org/ns/hydra/core#', - 'foo' => 'Output/foo', - ]); - } - - public function testGenIdFalseOnResource(): void - { - $r = self::createClient()->request( - 'GET', - '/gen_id_falsy', - ); - $this->assertJsonContains([ - 'aggregateRating' => ['ratingValue' => 2, 'ratingCount' => 3], - ]); - $this->assertArrayNotHasKey('@id', $r->toArray()['aggregateRating']); - } - - public function testGenIdFalseOnNestedResource(): void - { - $r = self::createClient()->request( - 'GET', - '/levelfirst/1', - ); - $res = $r->toArray(); - $this->assertArrayNotHasKey('@id', $res['levelSecond']); - $this->assertArrayHasKey('@id', $res['levelSecond'][0]['levelThird']); - } - - public function testShouldIgnoreProperty(): void - { - $r = self::createClient()->request( - 'GET', - '/contexts/GenIdFalse', - ); - $this->assertArrayNotHasKey('shouldBeIgnored', $r->toArray()['@context']); - } - - public function testIssue7298(): void - { - self::createClient()->request( - 'GET', - '/page_resources/page-1', - ); - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'modules' => [ - [ - '@type' => 'TitleModuleResource', - 'id' => 'title-module-1', - 'title' => 'My Title', - ], - [ - '@type' => 'ImageModule', - 'id' => 'image-module-1', - 'url' => 'http://example.com/image.jpg', - ], - ], - ]); - } - - public function testItemUriTemplate(): void - { - self::createClient()->request( - 'GET', - '/item_uri_template_recipes', - ); - $this->assertResponseIsSuccessful(); - - $this->assertJsonContains([ - 'member' => [ - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes/1', - 'name' => 'Dummy Recipe', - ], - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes/2', - 'name' => 'Dummy Recipe 2', - ], - ], - ]); - } - - public function testItemUriTemplateWithStateOption(): void - { - $container = static::getContainer(); - if ('mongodb' === $container->getParameter('kernel.environment')) { - $this->markTestSkipped(); - } - - $registry = $container->get('doctrine'); - $manager = $registry->getManager(); - for ($i = 0; $i < 10; ++$i) { - $recipe = new EntityRecipe(); - $recipe->name = "Recipe $i"; - $recipe->description = "Description of recipe $i"; - $recipe->author = "Author $i"; - $recipe->recipeIngredient = [ - "Ingredient 1 for recipe $i", - "Ingredient 2 for recipe $i", - ]; - $recipe->recipeInstructions = "Instructions for recipe $i"; - $recipe->prepTime = '10 minutes'; - $recipe->cookTime = '20 minutes'; - $recipe->totalTime = '30 minutes'; - $recipe->recipeCategory = "Category $i"; - $recipe->recipeCuisine = "Cuisine $i"; - $recipe->suitableForDiet = "Diet $i"; - - $manager->persist($recipe); - } - $manager->flush(); - - self::createClient()->request( - 'GET', - '/item_uri_template_recipes_state_option', - ); - $this->assertResponseIsSuccessful(); - - $this->assertJsonContains([ - 'member' => [ - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes_state_option/1', - 'name' => 'Recipe 0', - ], - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes_state_option/2', - 'name' => 'Recipe 1', - ], - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes_state_option/3', - 'name' => 'Recipe 2', - ], - ], - ]); - } - - /** - * Tests that @context uses the correct shortName when an entity has multiple ApiResource attributes. - */ - public function testMultiResourceContextUsesCorrectShortName(): void - { - if ($this->isMongoDB()) { - $this->markTestSkipped(); - } - - // Test the second declared ApiResource (shortName: 'MultiResource') - $response = self::createClient()->request('GET', '/multi_resources'); - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - '@context' => '/contexts/MultiResource', - ]); - - // Test the first declared ApiResource (shortName: 'AdminMultiResource') - $response = self::createClient()->request('GET', '/admin/multi_resources'); - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - '@context' => '/contexts/AdminMultiResource', - ]); - } - - protected function setUp(): void - { - self::bootKernel(); - - if ($this->isMongoDB()) { - $this->markTestSkipped('This test uses Doctrine ORM entities without MongoDB equivalents.'); - } - - $this->recreateSchema([Foo::class, Bar::class, EntityRecipe::class, MultiResourceEntity::class]); - - $manager = $this->getManager(); - $foo = new Foo(); - $foo->title = 'Foo'; - $manager->persist($foo); - $foo1 = new Foo(); - $foo1->title = 'Foo1'; - $manager->persist($foo1); - $bar = new Bar(); - $bar->title = 'Bar one'; - $manager->persist($bar); - $bar2 = new Bar(); - $bar2->title = 'Bar two'; - $manager->persist($bar2); - $multi = new MultiResourceEntity(); - $multi->title = 'Multi Resource'; - $manager->persist($multi); - $manager->flush(); - } -} From 11c76cd40a58e460deff67d8da5c26b2ad485e49 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 22 May 2026 16:15:49 +0200 Subject: [PATCH 2/7] test: migrate remaining trivial behat features to ApiTestCase (#7971) Co-authored-by: Claude Opus 4.7 (1M context) (cherry picked from commit e4684ef13c5e800bfe17e1b235d5884757e0263f) --- features/filter/filter_validation.feature | 109 ------- features/filter/property_filter.feature | 28 -- features/http_cache/headers.feature | 12 - .../http_cache/tag_collector_service.feature | 268 ------------------ features/http_cache/tags.feature | 142 ---------- features/issues/5926.feature | 36 --- features/json/input_output.feature | 40 --- features/json/relation.feature | 229 --------------- features/mercure/discover.feature | 13 - features/mercure/publish.feature | 60 ---- features/push_relations/push.feature | 17 -- .../sub_resources/multiple_relation.feature | 61 ---- features/xml/deserialization.feature | 92 ------ .../PropertyFilter/SparseFieldsetChild.php | 44 +++ .../PropertyFilter/SparseFieldsetParent.php | 57 ++++ ...SparseFieldsetParentWithQueryParameter.php | 54 ++++ .../Filter/FilterValidationTest.php | 147 ++++++++++ .../Functional/Filter/PropertyFilterTest.php | 100 +++++++ tests/Functional/HttpCache/CacheTagsTest.php | 201 +++++++++++++ tests/Functional/HttpCache/HeadersTest.php | 48 ++++ .../HttpCache/PushRelationsTest.php | 88 ++++++ .../Functional/HttpCache/TagCollectorTest.php | 227 +++++++++++++++ tests/Functional/Issue5926Test.php | 62 ++++ tests/Functional/Json/InputOutputTest.php | 67 +++++ tests/Functional/Json/RelationTest.php | 184 +++++++++++- tests/Functional/Mercure/MercureTest.php | 149 ++++++++++ .../SubResource/MultipleRelationTest.php | 94 ++++++ tests/Functional/Xml/DeserializationTest.php | 173 +++++++++++ tests/RecreateSchemaTrait.php | 7 + 29 files changed, 1693 insertions(+), 1116 deletions(-) delete mode 100644 features/filter/filter_validation.feature delete mode 100644 features/filter/property_filter.feature delete mode 100644 features/http_cache/headers.feature delete mode 100644 features/http_cache/tag_collector_service.feature delete mode 100644 features/http_cache/tags.feature delete mode 100644 features/issues/5926.feature delete mode 100644 features/json/input_output.feature delete mode 100644 features/json/relation.feature delete mode 100644 features/mercure/discover.feature delete mode 100644 features/mercure/publish.feature delete mode 100644 features/push_relations/push.feature delete mode 100644 features/sub_resources/multiple_relation.feature delete mode 100644 features/xml/deserialization.feature create mode 100644 tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParentWithQueryParameter.php create mode 100644 tests/Functional/Filter/FilterValidationTest.php create mode 100644 tests/Functional/Filter/PropertyFilterTest.php create mode 100644 tests/Functional/HttpCache/CacheTagsTest.php create mode 100644 tests/Functional/HttpCache/HeadersTest.php create mode 100644 tests/Functional/HttpCache/PushRelationsTest.php create mode 100644 tests/Functional/HttpCache/TagCollectorTest.php create mode 100644 tests/Functional/Issue5926Test.php create mode 100644 tests/Functional/Json/InputOutputTest.php create mode 100644 tests/Functional/Mercure/MercureTest.php create mode 100644 tests/Functional/SubResource/MultipleRelationTest.php create mode 100644 tests/Functional/Xml/DeserializationTest.php 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..3a3c280b0c9 --- /dev/null +++ b/tests/Functional/HttpCache/TagCollectorTest.php @@ -0,0 +1,227 @@ + + * + * 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', + ); + $this->assertJsonContains([ + '_embedded' => [ + 'anotherRelated' => [ + '_links' => ['self' => ['href' => '/related_dummies/1']], + ], + ], + ]); + } + + 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', + ); + $this->assertJsonContains([ + 'data' => [ + 'relationships' => [ + 'anotherRelated' => [ + 'data' => ['type' => 'RelatedDummy', 'id' => '/related_dummies/1'], + ], + ], + ], + ]); + } + + 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 index cbe39825f7f..d56ea46c187 100644 --- a/tests/Functional/Json/RelationTest.php +++ b/tests/Functional/Json/RelationTest.php @@ -14,21 +14,32 @@ 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 = false; + protected static ?bool $alwaysBootKernel = true; public static function getResources(): array { - return [ThirdLevel::class, RelatedDummy::class]; + return [ + ThirdLevel::class, + RelationEmbedder::class, + RelatedDummy::class, + Dummy::class, + ]; } protected function setUp(): void @@ -39,7 +50,130 @@ protected function setUp(): void $this->markTestSkipped('Not tested with MongoDB.'); } - $this->recreateSchema([ThirdLevel::class, RelatedDummy::class]); + $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, + ], + 'related' => 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, + ], + 'related' => 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', + '@type' => 'https://schema.org/Product', + 'symfony' => 'API Platform 2', + 'thirdLevel' => null, + ], + 'related' => null, + ]); } public function testCreateRelatedDummyWithPlainIdentifierForRelation(): void @@ -50,20 +184,52 @@ public function testCreateRelatedDummyWithPlainIdentifierForRelation(): void 'headers' => ['Content-Type' => 'application/json'], 'json' => ['level' => 3], ]); - $this->assertResponseStatusCodeSame(201); // RelatedDummyPlainIdentifierDenormalizer calls getIriFromResource(ThirdLevel::class, new Get(), …). // Without the fix the '_c' slot collision returns the GetCollection op, producing // "/third_levels?id=1" instead of "/third_levels/1". - $response = self::createClient()->request('POST', '/related_dummies', [ + 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, + ], + ]); + } - $data = $response->toArray(false); - $this->assertArrayHasKey('thirdLevel', $data); - $this->assertIsArray($data['thirdLevel']); - $this->assertSame('/third_levels/1', $data['thirdLevel']['@id']); + 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; } From 508041ca9e9fb582e51e664fb521d07db4776887 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 26 May 2026 08:06:05 +0200 Subject: [PATCH 3/7] test: eliminate behat-migration skips and tighten ported asserts (#8198) (cherry picked from commit e6922f1d7f6850bf201d19c736bb3e24823bae46) --- .github/workflows/ci.yml | 4 +- features/main/attribute_resource.feature | 120 --- features/main/circular_reference.feature | 89 -- features/main/composite.feature | 139 --- features/main/configurable.feature | 62 -- features/main/content_negotiation.feature | 171 ---- features/main/crud.feature | 782 ----------------- features/main/crud_abstract.feature | 167 ---- features/main/crud_uri_variables.feature | 206 ----- features/main/custom_controller.feature | 130 --- features/main/custom_identifier.feature | 121 --- ...custom_identifier_with_subresource.feature | 95 --- features/main/custom_normalized.feature | 213 ----- features/main/custom_put.feature | 29 - .../main/custom_writable_identifier.feature | 112 --- features/main/default_order.feature | 267 ------ features/main/exception_to_status.feature | 47 -- features/main/exposed_state.feature | 48 -- features/main/headers.feature | 14 - features/main/input_output.feature | 15 - features/main/not_exposed.feature | 204 ----- features/main/operation.feature | 97 --- features/main/operation_resource.feature | 66 -- features/main/overridden_operation.feature | 156 ---- features/main/patch.feature | 99 --- features/main/put_collection.feature | 32 - features/main/relation.feature | 546 ------------ .../serializable_item_data_provider.feature | 18 - features/main/standard_put.feature | 148 ---- features/main/sub_resource.feature | 633 -------------- features/main/table_inheritance.feature | 798 ------------------ features/main/union_intersect_types.feature | 121 --- features/main/url_encoded_id.feature | 26 - features/main/uuid.feature | 205 ----- features/main/validation.feature | 120 --- phpunit.baseline.xml | 3 + tests/Fixtures/TestBundle/Entity/Answer.php | 21 - .../TestBundle/Entity/FourthLevel.php | 8 +- .../Entity/OneToOneSubresourceAnswer.php | 70 ++ .../Entity/OneToOneSubresourceQuestion.php | 63 ++ tests/Fixtures/TestBundle/Entity/Question.php | 4 +- tests/Functional/AttributeResourceTest.php | 151 ++++ tests/Functional/CircularReferenceTest.php | 111 +++ tests/Functional/CompositeIdentifierTest.php | 170 ++++ tests/Functional/ConfigurableTest.php | 113 +++ tests/Functional/ContentNegotiationTest.php | 244 ++++++ tests/Functional/CrudAbstractTest.php | 171 ++++ tests/Functional/CrudTest.php | 180 ++++ tests/Functional/CrudUriVariablesTest.php | 206 +++++ tests/Functional/CustomControllerTest.php | 217 +++++ tests/Functional/CustomIdentifierTest.php | 172 ++++ .../CustomIdentifierWithSubresourceTest.php | 137 +++ tests/Functional/CustomNormalizedTest.php | 204 +++++ tests/Functional/CustomPutTest.php | 59 ++ .../CustomWritableIdentifierTest.php | 153 ++++ tests/Functional/DefaultOrderTest.php | 143 ++++ tests/Functional/ExceptionToStatusTest.php | 97 +++ tests/Functional/ExposedStateTest.php | 88 ++ tests/Functional/HeadersAdditionTest.php | 55 ++ .../Json/OutputAndEntityClassTest.php | 54 ++ .../SerializableItemDataProviderTest.php | 48 ++ tests/Functional/NotExposedTest.php | 165 ++++ tests/Functional/OperationResourceTest.php | 103 +++ tests/Functional/OperationTest.php | 155 ++++ tests/Functional/OverriddenOperationTest.php | 206 +++++ tests/Functional/PatchTest.php | 165 ++++ .../ProviderProcessorEntityTest.php | 132 +++ tests/Functional/PutCollectionTest.php | 71 ++ tests/Functional/RelationTest.php | 480 +++++++++++ tests/Functional/StandardPutTest.php | 189 +++++ .../SubResource/SubResourceTest.php | 596 +++++++++++++ tests/Functional/TableInheritanceTest.php | 301 +++++++ tests/Functional/UnionIntersectTypesTest.php | 103 +++ tests/Functional/UrlEncodedIdTest.php | 73 ++ tests/Functional/Uuid/UuidIdentifierTest.php | 302 +++++++ tests/Functional/ValidationGroupsTest.php | 131 +++ 76 files changed, 6091 insertions(+), 6123 deletions(-) delete mode 100644 features/main/attribute_resource.feature delete mode 100644 features/main/circular_reference.feature delete mode 100644 features/main/composite.feature delete mode 100644 features/main/configurable.feature delete mode 100644 features/main/content_negotiation.feature delete mode 100644 features/main/crud.feature delete mode 100644 features/main/crud_abstract.feature delete mode 100644 features/main/crud_uri_variables.feature delete mode 100644 features/main/custom_controller.feature delete mode 100644 features/main/custom_identifier.feature delete mode 100644 features/main/custom_identifier_with_subresource.feature delete mode 100644 features/main/custom_normalized.feature delete mode 100644 features/main/custom_put.feature delete mode 100644 features/main/custom_writable_identifier.feature delete mode 100644 features/main/default_order.feature delete mode 100644 features/main/exception_to_status.feature delete mode 100644 features/main/exposed_state.feature delete mode 100644 features/main/headers.feature delete mode 100644 features/main/input_output.feature delete mode 100644 features/main/not_exposed.feature delete mode 100644 features/main/operation.feature delete mode 100644 features/main/operation_resource.feature delete mode 100644 features/main/overridden_operation.feature delete mode 100644 features/main/patch.feature delete mode 100644 features/main/put_collection.feature delete mode 100644 features/main/relation.feature delete mode 100644 features/main/serializable_item_data_provider.feature delete mode 100644 features/main/standard_put.feature delete mode 100644 features/main/sub_resource.feature delete mode 100644 features/main/table_inheritance.feature delete mode 100644 features/main/union_intersect_types.feature delete mode 100644 features/main/url_encoded_id.feature delete mode 100644 features/main/uuid.feature delete mode 100644 features/main/validation.feature create mode 100644 tests/Fixtures/TestBundle/Entity/OneToOneSubresourceAnswer.php create mode 100644 tests/Fixtures/TestBundle/Entity/OneToOneSubresourceQuestion.php create mode 100644 tests/Functional/AttributeResourceTest.php create mode 100644 tests/Functional/CircularReferenceTest.php create mode 100644 tests/Functional/CompositeIdentifierTest.php create mode 100644 tests/Functional/ConfigurableTest.php create mode 100644 tests/Functional/ContentNegotiationTest.php create mode 100644 tests/Functional/CrudAbstractTest.php create mode 100644 tests/Functional/CrudTest.php create mode 100644 tests/Functional/CrudUriVariablesTest.php create mode 100644 tests/Functional/CustomControllerTest.php create mode 100644 tests/Functional/CustomIdentifierTest.php create mode 100644 tests/Functional/CustomIdentifierWithSubresourceTest.php create mode 100644 tests/Functional/CustomNormalizedTest.php create mode 100644 tests/Functional/CustomPutTest.php create mode 100644 tests/Functional/CustomWritableIdentifierTest.php create mode 100644 tests/Functional/DefaultOrderTest.php create mode 100644 tests/Functional/ExceptionToStatusTest.php create mode 100644 tests/Functional/ExposedStateTest.php create mode 100644 tests/Functional/HeadersAdditionTest.php create mode 100644 tests/Functional/Json/OutputAndEntityClassTest.php create mode 100644 tests/Functional/JsonLd/SerializableItemDataProviderTest.php create mode 100644 tests/Functional/NotExposedTest.php create mode 100644 tests/Functional/OperationResourceTest.php create mode 100644 tests/Functional/OperationTest.php create mode 100644 tests/Functional/OverriddenOperationTest.php create mode 100644 tests/Functional/PatchTest.php create mode 100644 tests/Functional/ProviderProcessorEntityTest.php create mode 100644 tests/Functional/PutCollectionTest.php create mode 100644 tests/Functional/RelationTest.php create mode 100644 tests/Functional/StandardPutTest.php create mode 100644 tests/Functional/SubResource/SubResourceTest.php create mode 100644 tests/Functional/TableInheritanceTest.php create mode 100644 tests/Functional/UnionIntersectTypesTest.php create mode 100644 tests/Functional/UrlEncodedIdTest.php create mode 100644 tests/Functional/Uuid/UuidIdentifierTest.php create mode 100644 tests/Functional/ValidationGroupsTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f4faca56e4..1467ffd7ba8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -455,12 +455,11 @@ jobs: matrix: php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} shard: - - main - graphql-doctrine - misc include: - php: '8.5' - shard: main + shard: graphql-doctrine coverage: true fail-fast: false steps: @@ -494,7 +493,6 @@ jobs: id: shard run: | case "${{ matrix.shard }}" in - main) paths="features/main" ;; graphql-doctrine) paths="features/graphql features/doctrine" ;; misc) paths="features/filter features/issues features/security features/serializer features/http_cache features/sub_resources features/json features/xml features/push_relations features/mercure" ;; esac diff --git a/features/main/attribute_resource.feature b/features/main/attribute_resource.feature deleted file mode 100644 index da92073e98f..00000000000 --- a/features/main/attribute_resource.feature +++ /dev/null @@ -1,120 +0,0 @@ -@php8 -@v3 -@!mysql -@!mongodb -Feature: Resource attributes - In order to use the Resource attribute - As a developer - I should be able to fetch data from a state provider - - Scenario: Retrieve a Resource collection - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/attribute_resources" - 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/AttributeResources", - "@id": "/attribute_resources", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/attribute_resources/1", - "@type": "AttributeResource", - "identifier": 1, - "name": "Foo" - }, - { - "@id": "/attribute_resources/2", - "@type": "AttributeResource", - "identifier": 2, - "name": "Bar" - } - ] - } - """ - - Scenario: Retrieve the first resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/attribute_resources/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/AttributeResource", - "@id": "/attribute_resources/1", - "@type": "AttributeResource", - "identifier": 1, - "name": "Foo" - } - """ - - Scenario: Retrieve the aliased resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummy/1/attribute_resources/2" - Then the response status code should be 301 - And the header "Location" should be equal to "/attribute_resources/2" - 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/AttributeResource", - "@id": "/attribute_resources/2", - "@type": "AttributeResource", - "identifier": 2, - "dummy": "/dummies/1", - "name": "Foo" - } - """ - - Scenario: Patch the aliased resource - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/dummy/1/attribute_resources/2" with body: - """ - {"name": "Patched"} - """ - Then the response status code should be 301 - And the header "Location" should be equal to "/attribute_resources/2" - 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/AttributeResource", - "@id": "/attribute_resources/2", - "@type": "AttributeResource", - "identifier": 2, - "dummy": "/dummies/1", - "name": "Patched" - } - """ - - Scenario: Uri variables should be configured properly - When I send a "GET" request to "/photos/1/resize/300/100" - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "detail" should be equal to 'Unable to generate an IRI for the item of type "ApiPlatform\Tests\Fixtures\TestBundle\Entity\IncompleteUriVariableConfigured"' - - Scenario: Uri variables with Post operation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/post_with_uri_variables_and_no_provider/{id}" with body: - """ - {} - """ - Then the response status code should be 201 - - Scenario: Throw validation exception in a provider - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/post_with_uri_variables/{id}" with body: - """ - {} - """ - Then the response status code should be 422 - diff --git a/features/main/circular_reference.feature b/features/main/circular_reference.feature deleted file mode 100644 index f53d44d9164..00000000000 --- a/features/main/circular_reference.feature +++ /dev/null @@ -1,89 +0,0 @@ -Feature: Circular references handling - In order to handle circular references - As a developer - I should be able to catch circular references. - - @createSchema - Scenario: Create a circular reference - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/circular_references" with body: - """ - {} - """ - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/circular_references/1" with body: - """ - { - "parent": "/circular_references/1" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CircularReference", - "@id": "/circular_references/1", - "@type": "CircularReference", - "parent": "/circular_references/1", - "children": [ - "/circular_references/1" - ] - } - """ - - Scenario: Fetch circular reference - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/circular_references" with body: - """ - {} - """ - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/circular_references/2" with body: - """ - { - "parent": "/circular_references/1" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CircularReference", - "@id": "/circular_references/2", - "@type": "CircularReference", - "parent": { - "@id": "/circular_references/1", - "@type": "CircularReference", - "parent": "/circular_references/1", - "children": [ - "/circular_references/1", - "/circular_references/2" - ] - }, - "children": [] - } - """ - And I send a "GET" request to "/circular_references/1" - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/CircularReference", - "@id": "/circular_references/1", - "@type": "CircularReference", - "parent": "/circular_references/1", - "children": [ - "/circular_references/1", - { - "@id": "/circular_references/2", - "@type": "CircularReference", - "parent": "/circular_references/1", - "children": [] - } - ] - } - """ diff --git a/features/main/composite.feature b/features/main/composite.feature deleted file mode 100644 index ab99527bd7f..00000000000 --- a/features/main/composite.feature +++ /dev/null @@ -1,139 +0,0 @@ -@!mongodb -Feature: Retrieve data with Composite identifiers - In order to retrieve relations with composite identifiers - As a client software developer - I need to retrieve all collections - - @createSchema - Scenario: Get a collection with composite identifiers - Given there are Composite identifier objects - When I send a "GET" request to "/composite_items" - 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/CompositeItem", - "@id": "/composite_items", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/composite_items/1", - "@type": "CompositeItem", - "id": 1, - "field1": "foobar", - "compositeValues": [ - "/composite_relations/compositeItem=1;compositeLabel=1", - "/composite_relations/compositeItem=1;compositeLabel=2", - "/composite_relations/compositeItem=1;compositeLabel=3", - "/composite_relations/compositeItem=1;compositeLabel=4" - ] - } - ], - "hydra:totalItems": 1 - } - """ - - @createSchema - Scenario: Get collection with composite identifiers - Given there are Composite identifier objects - When I send a "GET" request to "/composite_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/CompositeRelation", - "@id": "/composite_relations", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/composite_relations/compositeItem=1;compositeLabel=1", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/1" - }, - { - "@id": "/composite_relations/compositeItem=1;compositeLabel=2", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/2" - }, - { - "@id": "/composite_relations/compositeItem=1;compositeLabel=3", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/3" - } - ], - "hydra:totalItems": 4, - "hydra:view": { - "@id": "/composite_relations?page=1", - "@type": "hydra:PartialCollectionView", - "hydra:first": "/composite_relations?page=1", - "hydra:last": "/composite_relations?page=2", - "hydra:next": "/composite_relations?page=2" - } - } - """ - - @createSchema - Scenario: Get the first composite relation - Given there are Composite identifier objects - When I send a "GET" request to "/composite_relations/compositeItem=1;compositeLabel=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CompositeRelation", - "@id": "/composite_relations/compositeItem=1;compositeLabel=1", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/1" - } - """ - - @createSchema - Scenario: Get the first composite relation with a reverse identifiers order - Given there are Composite identifier objects - When I send a "GET" request to "/composite_relations/compositeLabel=1;compositeItem=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CompositeRelation", - "@id": "/composite_relations/compositeItem=1;compositeLabel=1", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/1" - } - """ - - @createSchema - Scenario: Get the first composite relation with a missing identifier - Given there are Composite identifier objects - When I send a "GET" request to "/composite_relations/compositeLabel=1;" - Then the response status code should be 404 - - Scenario: Get first composite item - Given there are Composite identifier objects - When I send a "GET" request to "/composite_items/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" - - Scenario: Get identifiers with different types - Given there are Composite identifier objects - When I send a "GET" request to "/composite_key_with_different_types/id=82133;verificationKey=7d75af772e637e45c36d041696e1128d" - Then the response status code should be 200 diff --git a/features/main/configurable.feature b/features/main/configurable.feature deleted file mode 100644 index c0e73d1ba2b..00000000000 --- a/features/main/configurable.feature +++ /dev/null @@ -1,62 +0,0 @@ -Feature: Configurable resource CRUD - As a client software developer - I need to be able to configure api resources through YAML - - @createSchema - Scenario: Retrieve the ConfigDummy resource - Given there is a FileConfigDummy object - When I send a "GET" request to "/fileconfigdummies" - 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/fileconfigdummy", - "@id": "/fileconfigdummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/fileconfigdummies/1", - "@type": "fileconfigdummy", - "id": 1, - "name": "ConfigDummy", - "foo": "Foo" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Get a single file configured resource - When I send a "GET" request to "/single_file_configs" - 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/single_file_config", - "@id": "/single_file_configs", - "@type": "hydra:Collection", - "hydra:member": [], - "hydra:totalItems": 0 - } - """ - - Scenario: Retrieve the ConfigDummy resource - When I send a "GET" request to "/fileconfigdummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/fileconfigdummy", - "@id": "/fileconfigdummies/1", - "@type": "fileconfigdummy", - "id": 1, - "name": "ConfigDummy", - "foo": "Foo" - } - """ diff --git a/features/main/content_negotiation.feature b/features/main/content_negotiation.feature deleted file mode 100644 index 7f22db3b396..00000000000 --- a/features/main/content_negotiation.feature +++ /dev/null @@ -1,171 +0,0 @@ -Feature: Content Negotiation support - In order to make the API supporting several input and output formats - As an API developer - I need to be able to specify the format I want to use - - @createSchema - Scenario: Post an XML body - When I add "Accept" header equal to "application/xml" - And I add "Content-Type" header equal to "application/xml" - And I send a "POST" request to "/dummies" with body: - """ - - XML! - - """ - Then the response status code should be 201 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 1XML! - """ - - Scenario: Retrieve a collection in XML - When I add "Accept" header equal to "text/xml" - And I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 1XML! - """ - - Scenario: Retrieve a collection in XML using the .xml URL - When I send a "GET" request to "/dummies.xml" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 1XML! - """ - - Scenario: Retrieve a collection in JSON - When I add "Accept" header equal to "application/json" - And I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json; charset=utf-8" - And the response should be in JSON - And the JSON should be equal to: - """ - [ - { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "XML!", - "alias": null, - "foo": null - } - ] - """ - - Scenario: Post a JSON document and retrieve an XML body - When I add "Accept" header equal to "application/xml" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/dummies" with body: - """ - {"name": "Sent in JSON"} - """ - Then the response status code should be 201 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 2Sent in JSON - """ - - Scenario: Requesting the same format in the Accept header and in the URL should work - When I add "Accept" header equal to "text/xml" - And I send a "GET" request to "/dummies/1.xml" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - - Scenario: Requesting any format in the Accept header should default to the first configured format - When I add "Accept" header equal to "*/*" - And I send a "GET" request to "/dummies/1" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - Scenario: Requesting any format in the Accept header should default to the format passed in the URL - When I add "Accept" header equal to "text/plain; charset=utf-8, */*" - And I send a "GET" request to "/dummies/1.xml" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - - Scenario: Requesting an unknown format should throw an error - When I add "Accept" header equal to "text/plain" - And I send a "GET" request to "/dummies/1" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: If the request format is HTML, the error should be in HTML - When I add "Accept" header equal to "text/html" - And I send a "GET" request to "/dummies/666" - Then the response status code should be 404 - And the header "Content-Type" should be equal to "text/html; charset=utf-8" - - Scenario: Retrieve a collection in JSON should not be possible if the format has been removed at resource level - When I add "Accept" header equal to "application/json" - And I send a "GET" request to "/dummy_custom_formats" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Post CSV body allowed on a single resource - When I add "Accept" header equal to "application/xml" - And I add "Content-Type" header equal to "text/csv" - And I send a "POST" request to "/dummy_custom_formats" with body: - """ - name - Kevin - """ - Then the response status code should be 201 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 1Kevin - """ - - Scenario: Retrieve a collection in CSV should be possible if the format is at resource level - When I add "Accept" header equal to "text/csv" - And I send a "GET" request to "/dummy_custom_formats" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "text/csv; charset=utf-8" - And the response should be equal to - """ - id,name - 1,Kevin - """ - - Scenario: Get a security response in JSON - Given there are 1 SecuredDummy objects - And I add "Accept" header equal to "application/json" - When I send a "GET" request to "/secured_dummies" - Then the response status code should be 401 - And the header "Content-Type" should be equal to "application/json" - And the response should be in JSON - And the JSON should be equal to: - """ - { - "message": "Authentication Required" - } - """ diff --git a/features/main/crud.feature b/features/main/crud.feature deleted file mode 100644 index 5933812bccc..00000000000 --- a/features/main/crud.feature +++ /dev/null @@ -1,782 +0,0 @@ -Feature: Create-Retrieve-Update-Delete - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve, create, update and delete JSON-LD encoded resources. - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "My Dummy", - "dummyDate": "2015-03-01T10:00:00+00:00", - "jsonData": { - "key": [ - "value1", - "value2" - ] - } - } - """ - 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 header "Content-Location" should be equal to "/dummies/1.jsonld" - And the header "Location" should be equal to "/dummies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": { - "key": [ - "value1", - "value2" - ] - }, - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "My Dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": { - "key": [ - "value1", - "value2" - ] - }, - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "My Dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Create a resource with empty body - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" - Then the response status code should be 400 - And the JSON node "detail" should be equal to "Syntax error" - - Scenario: Get a not found exception - When I send a "GET" request to "/dummies/42" - Then the response status code should be 404 - And the header "Content-Location" should not exist - - Scenario: Get a collection - When I send a "GET" request to "/dummies" - 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 header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": { - "key": [ - "value1", - "value2" - ] - }, - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "My Dummy", - "alias": null, - "foo": null - } - ], - "hydra:totalItems": 1, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummies{?dummyBoolean,relatedDummy.embeddedDummy.dummyBoolean,dummyDate[before],dummyDate[strictly_before],dummyDate[after],dummyDate[strictly_after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[strictly_before],relatedDummy.dummyDate[after],relatedDummy.dummyDate[strictly_after],exists[alias],exists[description],exists[relatedDummy.name],exists[dummyBoolean],exists[relatedDummy],exists[relatedDummies],dummyFloat,dummyFloat[],dummyPrice,dummyPrice[],order[id],order[name],order[description],order[relatedDummy.name],order[relatedDummy.symfony],order[dummyDate],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,relatedDummy.thirdLevel.level,relatedDummy.thirdLevel.level[],relatedDummy.thirdLevel.fourthLevel.level,relatedDummy.thirdLevel.fourthLevel.level[],relatedDummy.thirdLevel.badFourthLevel.level,relatedDummy.thirdLevel.badFourthLevel.level[],relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level,relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level[],name_converted,properties[]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "dummyBoolean", - "property": "dummyBoolean", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.embeddedDummy.dummyBoolean", - "property": "relatedDummy.embeddedDummy.dummyBoolean", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[after]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_after]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.dummyDate[before]", - "property": "relatedDummy.dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.dummyDate[strictly_before]", - "property": "relatedDummy.dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.dummyDate[after]", - "property": "relatedDummy.dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.dummyDate[strictly_after]", - "property": "relatedDummy.dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[alias]", - "property": "alias", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[description]", - "property": "description", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[relatedDummy.name]", - "property": "relatedDummy.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[dummyBoolean]", - "property": "dummyBoolean", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[relatedDummy]", - "property": "relatedDummy", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[relatedDummies]", - "property": "relatedDummies", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[id]", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[name]", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[description]", - "property": "description", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[relatedDummy.name]", - "property": "relatedDummy.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[relatedDummy.symfony]", - "property": "relatedDummy.symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[dummyDate]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[between]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[gt]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[gte]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[lt]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[lte]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[between]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[gt]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[gte]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[lt]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[lte]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id[]", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "alias", - "property": "alias", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "description", - "property": "description", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.name", - "property": "relatedDummy.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.name[]", - "property": "relatedDummy.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummies", - "property": "relatedDummies", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummies[]", - "property": "relatedDummies", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummy", - "property": "dummy", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummies.name", - "property": "relatedDummies.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.level", - "property": "relatedDummy.thirdLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.level[]", - "property": "relatedDummy.thirdLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.fourthLevel.level", - "property": "relatedDummy.thirdLevel.fourthLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.fourthLevel.level[]", - "property": "relatedDummy.thirdLevel.fourthLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.badFourthLevel.level", - "property": "relatedDummy.thirdLevel.badFourthLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.badFourthLevel.level[]", - "property": "relatedDummy.thirdLevel.badFourthLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level", - "property": "relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level[]", - "property": "relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name_converted", - "property": "name_converted", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "properties[]", - "property": null, - "required": false - } - ] - } - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummies/1" with body: - """ - { - "@id": "/dummies/1", - "name": "A nice dummy", - "dummyDate": "2018-12-01 13:12", - "jsonData": [{ - "key": "value1" - }, - { - "key": "value2" - } - ] - } - """ - 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 header "Content-Location" should be equal to "/dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2018-12-01T13:12:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [ - { - "key": "value1" - }, - { - "key": "value2" - } - ], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "A nice dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Update a resource with empty body - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummies/1" - Then the response status code should be 400 - And the JSON node "detail" should be equal to "Syntax error" - - Scenario: Delete a resource - When I send a "DELETE" request to "/dummies/1" - Then the response status code should be 204 - And the response should be empty - - @php8 - @createSchema - Scenario: Create a resource ProcessorEntity - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/processor_entities" with body: - """ - { - "foo": "bar" - } - """ - 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 header "Content-Location" should be equal to "/processor_entities/1.jsonld" - And the header "Location" should be equal to "/processor_entities/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ProcessorEntity", - "@id": "/processor_entities/1", - "@type": "ProcessorEntity", - "id": 1, - "foo": "bar" - } - """ - - @php8 - Scenario: Create a resource ProviderEntity - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/provider_entities" with body: - """ - { - "foo": "bar" - } - """ - 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 header "Content-Location" should be equal to "/provider_entities/1.jsonld" - And the header "Location" should be equal to "/provider_entities/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ProviderEntity", - "@id": "/provider_entities/1", - "@type": "ProviderEntity", - "id": 1, - "foo": "bar" - } - """ - - @php8 - Scenario: Get a collection of Provider Entities - When I send a "GET" request to "/provider_entities" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/ProviderEntity", - "@id": "/provider_entities", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/provider_entities/1", - "@type": "ProviderEntity", - "id": 1, - "foo": "bar" - } - ], - "hydra:totalItems": 1 - } - """ - - @php8 - Scenario: Get a resource ProviderEntity - When I send a "GET" request to "/provider_entities/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/ProviderEntity", - "@id": "/provider_entities/1", - "@type": "ProviderEntity", - "id": 1, - "foo": "bar" - } - """ - - Scenario: Get a resource in v3 configured in YAML - Given there is a Program - When I send a "GET" request to "/programs/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Program", - "@id": "/programs/1", - "@type": "Program", - "id": 1, - "name": "Lorem ipsum 1", - "date": "2015-03-01T10:00:00+00:00", - "author": "/users/1" - } - """ - - Scenario: Get a collection resource in v3 configured in YAML - Given there are 3 Programs - When I send a "GET" request to "/users/1/programs" - 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 header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Program", - "@id": "/users/1/programs", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/programs/1", - "@type": "Program", - "id": 1, - "name": "Lorem ipsum 1", - "date": "2015-03-01T10:00:00+00:00", - "author": "/users/1" - }, - { - "@id": "/programs/2", - "@type": "Program", - "id": 2, - "name": "Lorem ipsum 2", - "date": "2015-03-02T10:00:00+00:00", - "author": "/users/1" - }, - { - "@id": "/programs/3", - "@type": "Program", - "id": 3, - "name": "Lorem ipsum 3", - "date": "2015-03-03T10:00:00+00:00", - "author": "/users/1" - } - ], - "hydra:totalItems": 3 - } - """ - - Scenario: Get a resource in v3 configured in XML - Given there is a Comment - When I send a "GET" request to "/comments/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Comment", - "@id": "/comments/1", - "@type": "Comment", - "id": 1, - "comment": "Lorem ipsum dolor sit amet 1", - "date": "2015-03-01T10:00:00+00:00", - "author": "/users/1" - } - """ - - Scenario: Get a collection resource in v3 configured in XML - Given there are 3 Comments - When I send a "GET" request to "/users/1/comments" - 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 header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Comment", - "@id": "/users/1/comments", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/comments/1", - "@type": "Comment", - "id": 1, - "comment": "Lorem ipsum dolor sit amet 1", - "date": "2015-03-01T10:00:00+00:00", - "author": "/users/1" - }, - { - "@id": "/comments/2", - "@type": "Comment", - "id": 2, - "comment": "Lorem ipsum dolor sit amet 2", - "date": "2015-03-02T10:00:00+00:00", - "author": "/users/1" - }, - { - "@id": "/comments/3", - "@type": "Comment", - "id": 3, - "comment": "Lorem ipsum dolor sit amet 3", - "date": "2015-03-03T10:00:00+00:00", - "author": "/users/1" - } - ], - "hydra:totalItems": 3 - } - """ diff --git a/features/main/crud_abstract.feature b/features/main/crud_abstract.feature deleted file mode 100644 index fc8bd66c69a..00000000000 --- a/features/main/crud_abstract.feature +++ /dev/null @@ -1,167 +0,0 @@ -Feature: Create-Retrieve-Update-Delete on abstract resource - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve, create, update and delete JSON-LD encoded resources even if they are abstract. - - @createSchema - Scenario: Create a concrete resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/concrete_dummies" with body: - """ - { - "instance": "Concrete", - "name": "My Dummy" - } - """ - 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 header "Content-Location" should be equal to "/concrete_dummies/1.jsonld" - And the header "Location" should be equal to "/concrete_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Concrete", - "id": 1, - "name": "My Dummy" - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/abstract_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Concrete", - "id": 1, - "name": "My Dummy" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/abstract_dummies" - 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 header "Content-Location" should not exist - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^ConcreteDummy$" - }, - "instance": { - "type": "string", - "required": "true" - } - } - }, - "minItems": 1 - } - }, - "required": ["hydra:member"] - } - """ - - Scenario: Update a concrete resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/concrete_dummies/1" with body: - """ - { - "@id": "/concrete_dummies/1", - "instance": "Become real", - "name": "A nice dummy" - } - """ - 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 header "Content-Location" should be equal to "/concrete_dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Become real", - "id": 1, - "name": "A nice dummy" - } - """ - - Scenario: Update a concrete resource using abstract resource uri - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/abstract_dummies/1" with body: - """ - { - "@id": "/concrete_dummies/1", - "instance": "Become surreal", - "name": "A nicer dummy" - } - """ - 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 header "Content-Location" should be equal to "/concrete_dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Become surreal", - "id": 1, - "name": "A nicer dummy" - } - """ - - Scenario: Delete a resource - When I send a "DELETE" request to "/abstract_dummies/1" - Then the response status code should be 204 - And the response should be empty - - @createSchema - Scenario: Create a concrete resource with discriminator - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/abstract_dummies" with body: - """ - { - "discr": "concrete", - "instance": "Concrete", - "name": "My Dummy" - } - """ - 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 header "Content-Location" should be equal to "/concrete_dummies/1.jsonld" - And the header "Location" should be equal to "/concrete_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Concrete", - "id": 1, - "name": "My Dummy" - } - """ diff --git a/features/main/crud_uri_variables.feature b/features/main/crud_uri_variables.feature deleted file mode 100644 index 37787dc56d2..00000000000 --- a/features/main/crud_uri_variables.feature +++ /dev/null @@ -1,206 +0,0 @@ -Feature: Uri Variables - - @createSchema - @php8 - Scenario: Create a resource Company - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/companies" with body: - """ - { - "name": "Foo Company 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 header "Content-Location" should be equal to "/companies/1.jsonld" - And the header "Location" should be equal to "/companies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Company", - "@id": "/companies/1", - "@type": "Company", - "id": 1, - "name": "Foo Company 1", - "employees": null - } - """ - - @php8 - Scenario: Create a second resource Company - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/companies" with body: - """ - { - "name": "Foo Company 2" - } - """ - Then the response status code should be 201 - - @php8 - Scenario: Create first Employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/employees" with body: - """ - { - "name": "foo", - "company": "/companies/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 header "Content-Location" should be equal to "/companies/1/employees/1.jsonld" - And the header "Location" should be equal to "/companies/1/employees/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Employee", - "@id": "/companies/1/employees/1", - "@type": "Employee", - "id": 1, - "name": "foo", - "company": "/companies/1" - } - """ - - @php8 - Scenario: Create second Employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/employees" with body: - """ - { - "name": "foo2", - "company": "/companies/2" - } - """ - Then the response status code should be 201 - - @php8 - Scenario: Create third Employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/employees" with body: - """ - { - "name": "foo3", - "company": "/companies/2" - } - """ - Then the response status code should be 201 - - @php8 - Scenario: Retrieve the collection of employees - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/companies/2/employees" - 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 header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Employee", - "@id": "/companies/2/employees", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/companies/2/employees/2", - "@type": "Employee", - "name": "foo2", - "company": { - "@id": "/companies/2", - "@type": "Company", - "name": "Foo Company 2" - } - }, - { - "@id": "/companies/2/employees/3", - "@type": "Employee", - "name": "foo3", - "company": { - "@id": "/companies/2", - "@type": "Company", - "name": "Foo Company 2" - } - } - ], - "hydra:totalItems": 2 - } - """ - When I send the following GraphQL request: - """ - { - companies { - edges { - node { - name - employees { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.companies.edges[0].node.name" should be equal to "Foo Company 1" - And the JSON node "data.companies.edges[0].node.employees.edges" should have 1 element - And the JSON node "data.companies.edges[0].node.employees.edges[0].node.name" should be equal to "foo" - And the JSON node "data.companies.edges[1].node.name" should be equal to "Foo Company 2" - And the JSON node "data.companies.edges[1].node.employees.edges" should have 2 elements - And the JSON node "data.companies.edges[1].node.employees.edges[0].node.name" should be equal to "foo2" - And the JSON node "data.companies.edges[1].node.employees.edges[1].node.name" should be equal to "foo3" - - @php8 - Scenario: Retrieve the company of an employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/employees/1/company" - 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 header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Company", - "@id": "/employees/1/company", - "@type": "Company", - "id": 1, - "name": "Foo Company 1", - "employees": null - } - """ - - @php8 - Scenario: Retrieve an employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/companies/1/employees/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Employee", - "@id": "/companies/1/employees/1", - "@type": "Employee", - "id": 1, - "name": "foo", - "company": "/companies/1" - } - """ - - @php8 - Scenario: Trying to get an employee of wrong company - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/companies/1/employees/2" - Then the response status code should be 404 - And the header "Content-Location" should not exist diff --git a/features/main/custom_controller.feature b/features/main/custom_controller.feature deleted file mode 100644 index 16516099e53..00000000000 --- a/features/main/custom_controller.feature +++ /dev/null @@ -1,130 +0,0 @@ -@controller -Feature: Custom operation - As a client software developer - I need to be able to create custom operations - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - Scenario: Custom normalization operation - When I send a "POST" request to "/custom/denormalization" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/CustomActionDummy", - "@id": "/custom_action_dummies/1", - "@type": "CustomActionDummy", - "id": 1, - "foo": "custom!" - } - """ - - Scenario: Custom normalization operation - When I send a "GET" request to "/custom/1/normalization" - Then the JSON should be equal to: - """ - { - "id": 1, - "foo": "foo" - } - """ - - Scenario: Custom normalization operation with shorthand configuration - When I send a "POST" request to "/short_custom/denormalization" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/CustomActionDummy", - "@id": "/custom_action_dummies/2", - "@type": "CustomActionDummy", - "id": 2, - "foo": "short declaration" - } - """ - - Scenario: Custom normalization operation with shorthand configuration - When I send a "GET" request to "/short_custom/2/normalization" - Then the JSON should be equal to: - """ - { - "id": 2, - "foo": "short" - } - """ - - Scenario: Custom collection name without specific route - When I send a "GET" request to "/custom_action_collection_dummies" - Then the response status code should be 200 - Then the JSON node "hydra:member" should have 2 elements - - Scenario: Custom operation name without specific route - When I send a "GET" request to "/custom_action_collection_dummies/1" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/CustomActionDummy", - "@id": "/custom_action_collection_dummies/1", - "@type": "CustomActionDummy", - "id": 1, - "foo": "custom!" - } - """ - - @createSchema - Scenario: Create a payment - When I send a "POST" request to "/payments" with body: - """ - { - "amount": "123.45" - } - """ - 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/Payment", - "@id": "/payments/1", - "@type": "Payment", - "id": 1, - "amount": "123.45", - "voidPayment": null - } - """ - - @createSchema - Scenario: Void a payment - Given there is a payment - When I send a "POST" request to "/payments/1/void" - 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/VoidPayment", - "@id": "/void_payments/1", - "@type": "VoidPayment", - "id": 1, - "payment": "/payments/1" - } - """ - - Scenario: Get a void payment - When I send a "GET" request to "/void_payments/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoidPayment", - "@id": "/void_payments/1", - "@type": "VoidPayment", - "id": 1, - "payment": "/payments/1" - } - """ diff --git a/features/main/custom_identifier.feature b/features/main/custom_identifier.feature deleted file mode 100644 index 4af01787309..00000000000 --- a/features/main/custom_identifier.feature +++ /dev/null @@ -1,121 +0,0 @@ -Feature: Using custom identifier on resource - In order to use an hypermedia API - As a client software developer - I need to be able to user other identifier than id in resources - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/custom_identifier_dummies" with body: - """ - { - "name": "My Dummy" - } - """ - 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/CustomIdentifierDummy", - "@id": "/custom_identifier_dummies/1", - "@type": "CustomIdentifierDummy", - "customId": 1, - "name": "My Dummy" - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/custom_identifier_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomIdentifierDummy", - "@id": "/custom_identifier_dummies/1", - "@type": "CustomIdentifierDummy", - "customId": 1, - "name": "My Dummy" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/custom_identifier_dummies" - 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/CustomIdentifierDummy", - "@id": "/custom_identifier_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/custom_identifier_dummies/1", - "@type": "CustomIdentifierDummy", - "customId": 1, - "name": "My Dummy" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/custom_identifier_dummies/1" with body: - """ - { - "name": "My Dummy modified" - } - """ - 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/CustomIdentifierDummy", - "@id": "/custom_identifier_dummies/1", - "@type": "CustomIdentifierDummy", - "customId": 1, - "name": "My Dummy modified" - } - """ - - Scenario: API doc is correctly generated - When I send a "GET" request to "/docs.jsonld" - Then the response status code should be 200 - And the response should be in JSON - And the Hydra class "CustomIdentifierDummy" exists - And 4 operations are available for Hydra class "CustomIdentifierDummy" - And 1 properties are available for Hydra class "CustomIdentifierDummy" - And "name" property is readable for Hydra class "CustomIdentifierDummy" - And "name" property is writable for Hydra class "CustomIdentifierDummy" - - Scenario: Delete a resource - When I send a "DELETE" request to "/custom_identifier_dummies/1" - Then the response status code should be 204 - And the response should be empty - - @createSchema - Scenario: Get a resource - Given there is a custom multiple identifier dummy - When I send a "GET" request to "/custom_multiple_identifier_dummies/1/2" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomMultipleIdentifierDummy", - "@id": "/custom_multiple_identifier_dummies/1/2", - "@type": "CustomMultipleIdentifierDummy", - "firstId": 1, - "secondId": 2, - "name": "Orwell" - } - """ diff --git a/features/main/custom_identifier_with_subresource.feature b/features/main/custom_identifier_with_subresource.feature deleted file mode 100644 index 74d9c65a4cd..00000000000 --- a/features/main/custom_identifier_with_subresource.feature +++ /dev/null @@ -1,95 +0,0 @@ -Feature: Using custom parent identifier for resources - In order to use an hypermedia API - As a client software developer - I need to be able to use custom identifiers and query resources - - @createSchema - Scenario: Create a parent dummy - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/slug_parent_dummies" with body: - """ - { - "slug": "parent-dummy" - } - """ - 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/SlugParentDummy", - "@id": "/slug_parent_dummies/parent-dummy", - "@type": "SlugParentDummy", - "id": 1, - "slug": "parent-dummy", - "childDummies": [] - } - """ - - Scenario: Create a child dummy - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/slug_child_dummies" with body: - """ - { - "slug": "child-dummy", - "parentDummy": "/slug_parent_dummies/parent-dummy" - } - """ - 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/SlugChildDummy", - "@id": "/slug_child_dummies/child-dummy", - "@type": "SlugChildDummy", - "id": 1, - "slug": "child-dummy", - "parentDummy": "/slug_parent_dummies/parent-dummy" - } - """ - - Scenario: Get child dummies of parent dummy - When I send a "GET" request to "/slug_parent_dummies/parent-dummy/child_dummies" - 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/SlugChildDummy", - "@id": "/slug_parent_dummies/parent-dummy/child_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/slug_child_dummies/child-dummy", - "@type": "SlugChildDummy", - "id": 1, - "slug": "child-dummy", - "parentDummy": "/slug_parent_dummies/parent-dummy" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Get parent dummy of child dummy - When I send a "GET" request to "/slug_child_dummies/child-dummy/parent_dummy" - 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/SlugParentDummy", - "@id": "/slug_child_dummies/child-dummy/parent_dummy", - "@type": "SlugParentDummy", - "id": 1, - "slug": "parent-dummy", - "childDummies": [ - "/slug_child_dummies/child-dummy" - ] - } - """ diff --git a/features/main/custom_normalized.feature b/features/main/custom_normalized.feature deleted file mode 100644 index 7d1a49f3fed..00000000000 --- a/features/main/custom_normalized.feature +++ /dev/null @@ -1,213 +0,0 @@ -Feature: Using custom normalized entity - In order to use an hypermedia API - As a client software developer - I need to be able to filter correctly attribute of my entities - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/custom_normalized_dummies" with body: - """ - { - "name": "My Dummy", - "alias": "My alias" - } - """ - 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 header "Content-Location" should be equal to "/custom_normalized_dummies/1.jsonld" - And the header "Location" should be equal to "/custom_normalized_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy", - "alias": "My alias" - } - """ - - @createSchema - Scenario: Create a resource with a custom normalized dummy - When I add "Content-Type" header equal to "application/json" - When I add "Accept" header equal to "application/json" - And I send a "POST" request to "/related_normalized_dummies" with body: - """ - { - "name": "My Dummy" - } - """ - 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 header "Content-Location" should be equal to "/related_normalized_dummies/1.json" - And the header "Location" should be equal to "/related_normalized_dummies/1" - And the JSON should be equal to: - """ - { - "id": 1, - "name": "My Dummy", - "customNormalizedDummy": [] - } - """ - - @createSchema - Scenario: Create a resource with a custom normalized dummy and an id - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/custom_normalized_dummies" with body: - """ - { - "name": "My Dummy", - "alias": "My alias" - } - """ - Then the response status code should be 201 - When I add "Content-Type" header equal to "application/json" - When I add "Accept" header equal to "application/json" - And I send a "POST" request to "/related_normalized_dummies" with body: - """ - { - "name": "My Dummy" - } - """ - Then the response status code should be 201 - When I add "Content-Type" header equal to "application/json" - When I add "Accept" header equal to "application/json" - And I send a "PUT" request to "/related_normalized_dummies/1" with body: - """ - { - "name": "My Dummy", - "customNormalizedDummy":[{ - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy" - }] - } - """ - 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" - And the header "Content-Location" should be equal to "/related_normalized_dummies/1.json" - And the JSON should be equal to: - """ - { - "id": 1, - "name": "My Dummy", - "customNormalizedDummy":[{ - "id": 1, - "name": "My Dummy", - "alias": "My alias" - }] - } - """ - - Scenario: Get a custom normalized dummy resource - When I send a "GET" request to "/custom_normalized_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy", - "alias": "My alias" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/custom_normalized_dummies" - 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/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy", - "alias": "My alias" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Update a resource (legacy non-standard PUT) - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/custom_normalized_dummies/1" with body: - """ - { - "name": "My Dummy modified" - } - """ - 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 header "Content-Location" should be equal to "/custom_normalized_dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy modified", - "alias": "My alias" - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/custom_normalized_dummies/1" with body: - """ - { - "name": "My Dummy modified" - } - """ - 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 header "Content-Location" should be equal to "/custom_normalized_dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy modified", - "alias": "My alias" - } - """ - - Scenario: API doc is correctly generated - When I send a "GET" request to "/docs.jsonld" - Then the response status code should be 200 - And the response should be in JSON - And the Hydra class "CustomNormalizedDummy" exists - And 4 operations are available for Hydra class "CustomNormalizedDummy" - And 2 properties are available for Hydra class "CustomNormalizedDummy" - And "name" property is readable for Hydra class "CustomNormalizedDummy" - And "name" property is writable for Hydra class "CustomNormalizedDummy" - And "alias" property is readable for Hydra class "CustomNormalizedDummy" - And "alias" property is writable for Hydra class "CustomNormalizedDummy" - - Scenario: Delete a resource - When I send a "DELETE" request to "/custom_normalized_dummies/1" - Then the response status code should be 204 - And the response should be empty diff --git a/features/main/custom_put.feature b/features/main/custom_put.feature deleted file mode 100644 index 9b286b57cb4..00000000000 --- a/features/main/custom_put.feature +++ /dev/null @@ -1,29 +0,0 @@ -Feature: Spec-compliant PUT support - As a client software developer - I need to be able to create or replace resources using the PUT HTTP method - - @createSchema - @!mongodb - Scenario: Get a correct status code when updating a resource that is not allowed to read nor to create - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/custom_puts/1" with body: - """ - { - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 200 - And the response status code should not be 201 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomPut", - "@id": "/custom_puts/1", - "@type": "CustomPut", - "id": 1, - "foo": "a", - "bar": "b" - } - """ diff --git a/features/main/custom_writable_identifier.feature b/features/main/custom_writable_identifier.feature deleted file mode 100644 index 097d253b9ad..00000000000 --- a/features/main/custom_writable_identifier.feature +++ /dev/null @@ -1,112 +0,0 @@ -Feature: Using custom writable identifier on resource - In order to use an hypermedia API - As a client software developer - I need to be able to user other identifier than id in resource and set it via API call on POST / PUT. - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/custom_writable_identifier_dummies" with body: - """ - { - "name": "My Dummy", - "slug": "my_slug" - } - """ - 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 header "Content-Location" should be equal to "/custom_writable_identifier_dummies/my_slug.jsonld" - And the header "Location" should be equal to "/custom_writable_identifier_dummies/my_slug" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomWritableIdentifierDummy", - "@id": "/custom_writable_identifier_dummies/my_slug", - "@type": "CustomWritableIdentifierDummy", - "slug": "my_slug", - "name": "My Dummy" - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/custom_writable_identifier_dummies/my_slug" - 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/CustomWritableIdentifierDummy", - "@id": "/custom_writable_identifier_dummies/my_slug", - "@type": "CustomWritableIdentifierDummy", - "slug": "my_slug", - "name": "My Dummy" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/custom_writable_identifier_dummies" - 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/CustomWritableIdentifierDummy", - "@id": "/custom_writable_identifier_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/custom_writable_identifier_dummies/my_slug", - "@type": "CustomWritableIdentifierDummy", - "slug": "my_slug", - "name": "My Dummy" - } - ], - "hydra:totalItems": 1 - } - """ - - @!mongodb - Scenario: Update a resource (legacy non-standard PUT) - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/custom_writable_identifier_dummies/my_slug" with body: - """ - { - "name": "My Dummy modified", - "slug": "slug_modified" - } - """ - 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 header "Content-Location" should be equal to "/custom_writable_identifier_dummies/slug_modified.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomWritableIdentifierDummy", - "@id": "/custom_writable_identifier_dummies/slug_modified", - "@type": "CustomWritableIdentifierDummy", - "slug": "slug_modified", - "name": "My Dummy modified" - } - """ - - Scenario: API docs are correctly generated - When I send a "GET" request to "/docs.jsonld" - Then the response status code should be 200 - And the response should be in JSON - And the Hydra class "CustomWritableIdentifierDummy" exists - And 4 operations are available for Hydra class "CustomWritableIdentifierDummy" - And 2 properties are available for Hydra class "CustomWritableIdentifierDummy" - And "name" property is readable for Hydra class "CustomWritableIdentifierDummy" - And "name" property is writable for Hydra class "CustomWritableIdentifierDummy" - And "slug" property is readable for Hydra class "CustomWritableIdentifierDummy" - And "slug" property is writable for Hydra class "CustomWritableIdentifierDummy" - - @!mongodb - Scenario: Delete a resource - When I send a "DELETE" request to "/custom_writable_identifier_dummies/slug_modified" - Then the response status code should be 204 - And the response should be empty diff --git a/features/main/default_order.feature b/features/main/default_order.feature deleted file mode 100644 index ad05e08a835..00000000000 --- a/features/main/default_order.feature +++ /dev/null @@ -1,267 +0,0 @@ -Feature: Default order - In order to get a list in a specific order, - As a client software developer, - I need to be able to specify default order. - - @createSchema - Scenario: Override custom order - Given there are 5 foo objects with fake names - When I send a "GET" request to "/foos?itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Foo", - "@id": "/foos", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/foos/5", - "@type": "Foo", - "id": 5, - "name": "Balbo", - "bar": "Amet" - }, - { - "@id": "/foos/3", - "@type": "Foo", - "id": 3, - "name": "Ephesian", - "bar": "Dolor" - }, - { - "@id": "/foos/2", - "@type": "Foo", - "id": 2, - "name": "Sthenelus", - "bar": "Ipsum" - }, - { - "@id": "/foos/1", - "@type": "Foo", - "id": 1, - "name": "Hawsepipe", - "bar": "Lorem" - }, - { - "@id": "/foos/4", - "@type": "Foo", - "id": 4, - "name": "Separativeness", - "bar": "Sit" - } - ], - "hydra:totalItems": 5, - "hydra:view": { - "@id": "/foos?itemsPerPage=10", - "@type": "hydra:PartialCollectionView" - } - } - """ - - Scenario: Override custom order by association - Given there are 5 fooDummy objects with fake names - When I send a "GET" request to "/foo_dummies?itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/FooDummy", - "@id": "/foo_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/foo_dummies/5", - "@type": "FooDummy", - "id": 5, - "name": "Balbo", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/5", - "soManies": [ - "/so_manies/13", - "/so_manies/14", - "/so_manies/15" - ] - - }, - { - "@id": "/foo_dummies/3", - "@type": "FooDummy", - "id": 3, - "name": "Sthenelus", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/3", - "soManies": [ - "/so_manies/7", - "/so_manies/8", - "/so_manies/9" - ] - }, - { - "@id": "/foo_dummies/2", - "@type": "FooDummy", - "id": 2, - "name": "Ephesian", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/2", - "soManies": [ - "/so_manies/4", - "/so_manies/5", - "/so_manies/6" - ] - }, - { - "@id": "/foo_dummies/1", - "@type": "FooDummy", - "id": 1, - "name": "Hawsepipe", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/1", - "soManies": [ - "/so_manies/1", - "/so_manies/2", - "/so_manies/3" - ] - }, - { - "@id": "/foo_dummies/4", - "@type": "FooDummy", - "id": 4, - "name": "Separativeness", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/4", - "soManies": [ - "/so_manies/10", - "/so_manies/11", - "/so_manies/12" - ] - } - ], - "hydra:totalItems": 5, - "hydra:view": { - "@id": "/foo_dummies?itemsPerPage=10", - "@type": "hydra:PartialCollectionView" - } - } - """ - - Scenario: Override custom order asc - When I send a "GET" request to "/custom_collection_asc_foos?itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Foo", - "@id": "/custom_collection_asc_foos", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/foos/5", - "@type": "Foo", - "id": 5, - "name": "Balbo", - "bar": "Amet" - }, - { - "@id": "/foos/3", - "@type": "Foo", - "id": 3, - "name": "Ephesian", - "bar": "Dolor" - }, - { - "@id": "/foos/1", - "@type": "Foo", - "id": 1, - "name": "Hawsepipe", - "bar": "Lorem" - }, - { - "@id": "/foos/4", - "@type": "Foo", - "id": 4, - "name": "Separativeness", - "bar": "Sit" - }, - { - "@id": "/foos/2", - "@type": "Foo", - "id": 2, - "name": "Sthenelus", - "bar": "Ipsum" - } - ], - "hydra:totalItems": 5, - "hydra:view": { - "@id": "/custom_collection_asc_foos?itemsPerPage=10", - "@type": "hydra:PartialCollectionView" - } - } - """ - - Scenario: Override custom order desc - When I send a "GET" request to "/custom_collection_desc_foos?itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Foo", - "@id": "/custom_collection_desc_foos", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/foos/2", - "@type": "Foo", - "id": 2, - "name": "Sthenelus", - "bar": "Ipsum" - }, - { - "@id": "/foos/4", - "@type": "Foo", - "id": 4, - "name": "Separativeness", - "bar": "Sit" - }, - { - "@id": "/foos/1", - "@type": "Foo", - "id": 1, - "name": "Hawsepipe", - "bar": "Lorem" - }, - { - "@id": "/foos/3", - "@type": "Foo", - "id": 3, - "name": "Ephesian", - "bar": "Dolor" - }, - { - "@id": "/foos/5", - "@type": "Foo", - "id": 5, - "name": "Balbo", - "bar": "Amet" - } - ], - "hydra:totalItems": 5, - "hydra:view": { - "@id": "/custom_collection_desc_foos?itemsPerPage=10", - "@type": "hydra:PartialCollectionView" - } - } - """ diff --git a/features/main/exception_to_status.feature b/features/main/exception_to_status.feature deleted file mode 100644 index a182ea848a8..00000000000 --- a/features/main/exception_to_status.feature +++ /dev/null @@ -1,47 +0,0 @@ -Feature: Using exception_to_status config - As an API developer - I can customize the status code returned if the application throws an exception - - @createSchema - @!mongodb - Scenario: Configure status code via the operation exceptionToStatus to map custom NotFound error to 404 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummy_exception_to_statuses/123" - 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" - - @!mongodb - Scenario: Configure status code via the resource exceptionToStatus to map custom NotFound error to 400 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_exception_to_statuses/123" with body: - """ - { - "name": "black" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - Scenario: Configure status code via the config file to map FilterValidationException to 400 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummy_exception_to_statuses" - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - Scenario: Override validation exception status code from delete operation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "DELETE" request to "/error_with_overriden_status/1" - Then the response status code should be 403 - And the JSON node "status" should be equal to 403 - - @!mongodb - Scenario: Get HTTP Exception headers - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/issue5924" - Then the response status code should be 429 - Then the header "retry-after" should be equal to 32 diff --git a/features/main/exposed_state.feature b/features/main/exposed_state.feature deleted file mode 100644 index 1915732f380..00000000000 --- a/features/main/exposed_state.feature +++ /dev/null @@ -1,48 +0,0 @@ -@postgres -Feature: Expose persisted object state - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve the exact state of resources after persistence. - - @!mongodb - @createSchema - Scenario: Create a resource with truncable value should return the correct object state - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/truncated_dummies" with body: - """ - { - "value": "20.3325" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "/contexts/TruncatedDummy", - "@id": "/truncated_dummies/1", - "@type": "TruncatedDummy", - "value": "20.3", - "id": 1 - } - """ - - @!mongodb - Scenario: Update a resource with truncable value value should return the correct object state - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/truncated_dummies/1" with body: - """ - { - "value": "42.42" - } - """ - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/TruncatedDummy", - "@id": "/truncated_dummies/1", - "@type": "TruncatedDummy", - "value": "42.4", - "id": 1 - } - """ diff --git a/features/main/headers.feature b/features/main/headers.feature deleted file mode 100644 index d61e6769574..00000000000 --- a/features/main/headers.feature +++ /dev/null @@ -1,14 +0,0 @@ -Feature: Headers addition - - @createSchema - Scenario: Test Sunset header addition - Given there is a DummyCar entity with related colors - When I send a "GET" request to "/dummy_cars" - Then the response status code should be 200 - And the header "Sunset" should be equal to "Sat, 01 Jan 2050 00:00:00 +0000" - - Scenario: Declare headers from resource - When I send a "GET" request to "/redirect_to_foobar" - Then the response status code should be 301 - And the header "Location" should be equal to "/foobar" - And the header "Hello" should be equal to "World" diff --git a/features/main/input_output.feature b/features/main/input_output.feature deleted file mode 100644 index 0c6f49926d9..00000000000 --- a/features/main/input_output.feature +++ /dev/null @@ -1,15 +0,0 @@ -Feature: DTO input and output - In order to use a hypermedia 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/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @!mongodb - Scenario: Fetch a collection of outputs with an entityClass as state option - When I send a "GET" request to "/output_and_entity_classes" - And the JSON node "hydra:member[0].@type" should be equal to "OutputAndEntityClassEntity" - - diff --git a/features/main/not_exposed.feature b/features/main/not_exposed.feature deleted file mode 100644 index 809b95dd8e5..00000000000 --- a/features/main/not_exposed.feature +++ /dev/null @@ -1,204 +0,0 @@ -@php8 -@v3 -Feature: Expose only a collection of objects - - Background: - Given I add "Accept" header equal to "application/ld+json" - - # A NotExposed operation with "routeName: api_genid" is automatically added to this resource. - Scenario: Get a collection of objects without identifiers from a single resource with a single collection - When I send a "GET" request to "/chairs" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Chair$"}, - "@id": {"pattern": "^/chairs$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/.well-known/genid/.+$"}, - "@type": {"pattern": "^Chair$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - # A NotExposed operation with a valid path (e.g.: "/tables/{id}") is automatically added to this resource. - Scenario: Get a collection of objects with identifiers from a single resource with a single collection - When I send a "GET" request to "/tables" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Table$"}, - "@id": {"pattern": "^/tables$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/tables/.+$"}, - "@type": {"pattern": "^Table$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - # A NotExposed operation with a valid path (e.g.: "/forks/{id}") is automatically added to the last resource. - # This operation does not inherit from the resource uriTemplate as it's not intended to. - Scenario Outline: Get a collection of objects with identifiers from a multiple resources class with multiple collections - When I send a "GET" request to "" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Fork$"}, - "@id": {"pattern": "^"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/forks/.+$"}, - "@type": {"pattern": "^Fork$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - Examples: - | uri | - | /forks | - | /fourchettes | - - - # A NotExposed operation is not automatically added. - Scenario Outline: Get a collection of objects with identifiers from a multiple resources class with multiple collections and an item operation - When I send a "GET" request to "" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Spoon$"}, - "@id": {"pattern": "^"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/cuillers/.+$"}, - "@type": {"pattern": "^Spoon$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - Examples: - | uri | - | /spoons | - | /cuillers | - - Scenario Outline: Get a not exposed route returns a 404 with an explanation - When I send a "GET" request to "" - Then the response status code should be 404 - And the response should be in JSON - And the JSON node "detail" should be equal to "" - Examples: - | uri | description | - | /tables/12345 | This route does not aim to be called. | - | /forks/12345 | This route does not aim to be called. | - - Scenario Outline: Get a not exposed route returns a 404 with an explanation - When I send a "GET" request to "" - Then the response status code should be 404 - And the response should be in JSON - And the JSON node "detail" should be equal to "" - Examples: - | uri | description | - | /.well-known/genid/12345 | This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation. | - - - Scenario: Get a single item still works - When I send a "GET" request to "/cuillers/12345" - 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/Spoon", - "@id": "/cuillers/12345", - "@type": "Spoon", - "id": "12345", - "owner": "Vincent" - } - """ diff --git a/features/main/operation.feature b/features/main/operation.feature deleted file mode 100644 index 24a82890cfd..00000000000 --- a/features/main/operation.feature +++ /dev/null @@ -1,97 +0,0 @@ -Feature: Operation support - In order to make the API fitting custom need - As an API developer - I need to be able to add custom operations and remove built-in ones - - @createSchema - Scenario: Can not write readonly property - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/readable_only_properties" with body: - """ - { - "name": "My Dummy" - } - """ - 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/ReadableOnlyProperty", - "@id": "/readable_only_properties/1", - "@type": "ReadableOnlyProperty", - "id": 1, - "name": "Read only" - } - """ - - Scenario: Access custom operations - When I send a "GET" request to "/relation_embedders/42/custom" - 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: - """ - "This is a custom action for 42." - """ - - @createSchema - Scenario: Select a resource and it's embedded data - Given there are 1 embedded dummy objects - When I send a "GET" request to "/embedded_dummies_groups/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/EmbeddedDummy", - "@id": "/embedded_dummies_groups/1", - "@type": "EmbeddedDummy", - "name": "Dummy #1", - "embeddedDummy": { - "@type": "EmbeddableDummy", - "dummyName": "Dummy #1" - } - } - """ - - Scenario: Get the collection of a resource that have disabled item operation - When I send a "GET" request to "/disable_item_operations" - Then the response status code should be 200 - - Scenario: Get a 404 response for the disabled item operation - When I send a "GET" request to "/disable_item_operations/1" - Then the response status code should be 404 - - @createSchema - Scenario: Get a book by its ISBN - Given there is a book - When I send a "GET" request to "books/by_isbn/9780451524935" - 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/Book", - "@id": "/books/by_isbn/9780451524935", - "@type": "Book", - "name": "1984", - "isbn": "9780451524935", - "id": 1 - } - """ - - Scenario: Call a non API Platform route - When I send a "GET" request to "/common/custom/object" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "id": 1, - "text": "Lorem ipsum dolor sit amet" - } - """ diff --git a/features/main/operation_resource.feature b/features/main/operation_resource.feature deleted file mode 100644 index b4bd729fcaf..00000000000 --- a/features/main/operation_resource.feature +++ /dev/null @@ -1,66 +0,0 @@ -Feature: Resource operations - In order to use the Resource Operation - As a developer - I should be able to persist data from a processor - - @php8 - @createSchema - @!mongodb - Scenario: Create an operation resource - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/operation_resources" with body: - """ - { - "identifier": 1, - "dummy": null, - "name": "string" - } - """ - Then the response status code should be 201 - - @php8 - @!mongodb - Scenario: Patch an operation resource - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/operation_resources/1" with body: - """ - {"name": "Patched"} - """ - 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/OperationResource", - "@id": "/operation_resources/1", - "@type": "OperationResource", - "identifier": 1, - "name": "Patched" - } - """ - - @php8 - @!mongodb - Scenario: Update an operation resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/operation_resources/1" with body: - """ - { - "name": "Modified" - } - """ - 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 header "Content-Location" should be equal to "/operation_resources/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OperationResource", - "@id": "/operation_resources/1", - "@type": "OperationResource", - "identifier": 1, - "name": "Modified" - } - """ diff --git a/features/main/overridden_operation.feature b/features/main/overridden_operation.feature deleted file mode 100644 index d07181de8bd..00000000000 --- a/features/main/overridden_operation.feature +++ /dev/null @@ -1,156 +0,0 @@ -Feature: Create-Retrieve-Update-Delete with a Overridden Operation context - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve, create, update and delete JSON-LD encoded resources. - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/overridden_operation_dummies" with body: - """ - { - "name": "My Overridden Operation Dummy", - "description" : "Gerard", - "alias": "notWritable" - } - """ - 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/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "name": "My Overridden Operation Dummy", - "alias": null, - "description": "Gerard" - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/overridden_operation_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "name": "My Overridden Operation Dummy", - "alias": null, - "description": "Gerard" - } - """ - - Scenario: Get a resource in XML - When I add "Accept" header equal to "application/xml" - And I send a "GET" request to "/overridden_operation_dummies/1" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be equal to - """ - - My Overridden Operation DummyGerard - """ - - Scenario: Get a not found exception - When I send a "GET" request to "/overridden_operation_dummies/42" - Then the response status code should be 404 - - Scenario: Get a collection - When I send a "GET" request to "/overridden_operation_dummies" - 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/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "name": "My Overridden Operation Dummy", - "alias": null, - "description": "Gerard" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/overridden_operation_dummies/1" with body: - """ - { - "@id": "/overridden_operation_dummies/1", - "name": "A nice dummy", - "alias": "Dummy" - } - """ - 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/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "alias": "Dummy", - "description": "Gerard" - } - """ - - Scenario: Get the final resource - When I send a "GET" request to "/overridden_operation_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "name": "My Overridden Operation Dummy", - "alias": "Dummy", - "description": "Gerard" - } - """ - - Scenario: Delete a resource - When I send a "DELETE" request to "/overridden_operation_dummies/1" - Then the response status code should be 204 - And the response should be empty - - @createSchema - Scenario: Use a POST operation to do a Remote Procedure Call without identifiers - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/rpc" - """ - { - "value": "Hello world" - } - """ - Then the response status code should be 202 - - @createSchema - Scenario: Use a POST operation to do a Remote Procedure Call without identifiers and with an output DTO - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/rpc_output" - """ - { - "value": "Hello world" - } - """ - Then the response status code should be 200 - And the JSON node "success" should be equal to "YES" - And the JSON node "@type" should be equal to "RPCOutput" diff --git a/features/main/patch.feature b/features/main/patch.feature deleted file mode 100644 index d6be7ec5437..00000000000 --- a/features/main/patch.feature +++ /dev/null @@ -1,99 +0,0 @@ -Feature: Sending PATCH requets - As a client software developer - I need to be able to send partial updates - - @createSchema - Scenario: Detect accepted patch formats - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/patch_dummies" with body: - """ - {"name": "Hello"} - """ - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/patch_dummies/1" - Then the header "Accept-Patch" should be equal to "application/merge-patch+json, application/vnd.api+json" - - Scenario: Patch an item - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/patch_dummies/1" with body: - """ - {"name": "Patched"} - """ - Then the JSON node "name" should contain "Patched" - - Scenario: Remove a property according to RFC 7386 - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/patch_dummies/1" with body: - """ - {"name": null} - """ - Then the JSON node "name" should not exist - - @createSchema - Scenario: Patch the relation - Given there is a PatchDummyRelation - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/patch_dummy_relations/1" with body: - """ - { - "related": { - "symfony": "A new name" - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/PatchDummyRelation", - "@id": "/patch_dummy_relations/1", - "@type": "PatchDummyRelation", - "related": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "id": 1, - "symfony": "A new name" - } - } - """ - - Scenario: Patch a relation with uri variables that are not `id` - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/betas/1" with body: - """ - { - "alpha": "/alphas/2" - } - """ - Then the response should be in JSON - And the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Beta", - "@id": "/betas/1", - "@type": "Beta", - "betaId": 1, - "alpha": "/alphas/2" - } - """ - - @use_listener - @controller - # Previously to 3.3 it was not possible to disable a read, this test is ignored on the - # legacy test suite (EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER=1) - Scenario: Patch a non-readable resource - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/order_products/1/count" with body: - """ - { - "id": 1, - "count": 10 - } - - """ - Then the response status code should be 200 - And the JSON node "id" should contain "1" diff --git a/features/main/put_collection.feature b/features/main/put_collection.feature deleted file mode 100644 index 2423043b886..00000000000 --- a/features/main/put_collection.feature +++ /dev/null @@ -1,32 +0,0 @@ -Feature: Update an embed collection with PUT - As a client software developer - I need to be able to update an embed collection - - Background: - Given I add "Content-Type" header equal to "application/ld+json" - - @createSchema - @!mongodb - Scenario: Update embed collection - And I send a "POST" request to "/issue5584_employees" with body: - """ - {"name": "One"} - """ - Then I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue5584_employees" with body: - """ - {"name": "Two"} - """ - Then print last JSON response - Then I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue5584_businesses" with body: - """ - {"name": "Business"} - """ - Then I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/issue5584_businesses/1" with body: - """ - {"name": "Business", "businessEmployees": [{"@id": "/issue5584_employees/1", "id": 1}, {"@id": "/issue5584_employees/2", "id": 2}]} - """ - And the JSON node "businessEmployees[0].name" should contain 'One' - And the JSON node "businessEmployees[1].name" should contain 'Two' diff --git a/features/main/relation.feature b/features/main/relation.feature deleted file mode 100644 index 5eba540f96e..00000000000 --- a/features/main/relation.feature +++ /dev/null @@ -1,546 +0,0 @@ -Feature: 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/ld+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 dummy friend - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_friends" with body: - """ - {"name": "Zoidberg"} - """ - 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/DummyFriend", - "@id": "/dummy_friends/1", - "@type": "DummyFriend", - "id": 1, - "name": "Zoidberg" - } - """ - - Scenario: Create a related dummy - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/related_dummies" with body: - """ - {"thirdLevel": "/third_levels/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/1", - "@type": "https://schema.org/Product", - "id": 1, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": null - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - """ - - @!mongodb - Scenario: Create a friend relationship - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/related_to_dummy_friends" with body: - """ - { - "name": "Friends relation", - "dummyFriend": "/dummy_friends/1", - "relatedDummy": "/related_dummies/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/RelatedToDummyFriend", - "@id": "/related_to_dummy_friends/dummyFriend=1;relatedDummy=1", - "@type": "RelatedToDummyFriend", - "name": "Friends relation", - "description": null, - "dummyFriend": { - "@id": "/dummy_friends/1", - "@type": "DummyFriend", - "name": "Zoidberg" - } - } - """ - - @!mongodb - Scenario: Get the relationship - When I send a "GET" request to "/related_to_dummy_friends/dummyFriend=1;relatedDummy=1" - And 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/RelatedToDummyFriend", - "@id": "/related_to_dummy_friends/dummyFriend=1;relatedDummy=1", - "@type": "RelatedToDummyFriend", - "name": "Friends relation", - "description": null, - "dummyFriend": { - "@id": "/dummy_friends/1", - "@type": "DummyFriend", - "name": "Zoidberg" - } - } - """ - - Scenario: Create a dummy with relations - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Dummy with relations", - "relatedDummy": "http://example.com/related_dummies/1", - "relatedDummies": [ - "/related_dummies/1" - ], - "name_converted": null - } - """ - 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 relations", - "alias": null, - "foo": null - } - """ - - Scenario: Filter on a relation - When I send a "GET" request to "/dummies?relatedDummy=%2Frelated_dummies%2F1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/1$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy=%2Frelated_dummies%2F1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter on a to-many relation - When I send a "GET" request to "/dummies?relatedDummies[]=%2Frelated_dummies%2F1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/1$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummies%5B%5D=%2Frelated_dummies%2F1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Embed a relation in the parent object - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": "/related_dummies/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/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": null, - "related": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "symfony", - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "level": 3, - "fourthLevel": null - } - } - } - """ - - Scenario: Create an existing relation - When I add "Content-Type" header equal to "application/ld+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/2", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/2", - "@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/ld+json" - And I send a "PUT" request to "/relation_embedders/2" 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/2", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/3", - "@type": "https://schema.org/Product", - "symfony": "laravel2", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Post a wrong relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "@id": "/related_dummies/123", - "@type": "https://schema.org/Product", - "symfony": "phalcon" - } - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Post a relation with a not existing IRI - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": "/related_dummies/123" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Update an embedded relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation_embedders/2" with body: - """ - { - "anotherRelated": { - "@id": "/related_dummies/2", - "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/2", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/2", - "@type": "https://schema.org/Product", - "symfony": "API Platform", - "thirdLevel": null - }, - "related": null - } - """ - - @createSchema - Scenario: Eager load relations should not be duplicated - Given there is an order with same customer and recipient - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/orders" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/Order", - "@id": "/orders", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/orders/1", - "@type": "Order", - "id": 1, - "customer": { - "@id": "/customers/1", - "@type": "Customer", - "id": 1, - "name": "customer_name", - "addresses": [ - { - "@id": "/addresses/1", - "@type": "Address", - "id": 1, - "name": "foo" - }, - { - "@id": "/addresses/2", - "@type": "Address", - "id": 2, - "name": "bar" - } - ] - }, - "recipient": { - "@id": "/customers/1", - "@type": "Customer", - "id": 1, - "name": "customer_name", - "addresses": [ - { - "@id": "/addresses/1", - "@type": "Address", - "id": 1, - "name": "foo" - }, - { - "@id": "/addresses/2", - "@type": "Address", - "id": 2, - "name": "bar" - } - ] - } - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Passing an invalid IRI to a relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": "certainly not an IRI" - } - """ - Then the response status code should be 400 - 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 contain 'Invalid IRI "certainly not an IRI".' - - Scenario: Passing an invalid type to a relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": 8 - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^hydra:Error$" - }, - "hydra:title": { - "type": "string", - "pattern": "^An error occurred$" - }, - "detail": { - "pattern": "^The type of the \"ApiPlatform\\\\Tests\\\\Fixtures\\\\TestBundle\\\\(Document|Entity)\\\\RelatedDummy\" resource must be \"array\" \\(nested document\\) or \"string\" \\(IRI\\), \"integer\" given.$" - } - }, - "required": [ - "@type", - "hydra:title", - "detail" - ] - } - """ - - @createSchema - Scenario: Issue #1222 - Given there are people having pets - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/people" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be a superset of: - """ - { - "@context": "/contexts/Person", - "@id": "/people", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/people/1", - "@type": "Person", - "name": "foo", - "pets": [ - { - "@type": "PersonToPet", - "pet": { - "@id": "/pets/1", - "@type": "Pet", - "name": "bar" - } - } - ] - } - ], - "hydra:totalItems": 1 - } - """ diff --git a/features/main/serializable_item_data_provider.feature b/features/main/serializable_item_data_provider.feature deleted file mode 100644 index 11a111cf101..00000000000 --- a/features/main/serializable_item_data_provider.feature +++ /dev/null @@ -1,18 +0,0 @@ -Feature: Serializable item data provider - In order to call any external API - As a developer - I should be able to serialize the response directly from the ItemDataProvider. - - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/serializable_resources/1" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/SerializableResource", - "@id": "/serializable_resources/1", - "@type": "SerializableResource", - "id": 1, - "foo": "Lorem", - "bar": "Ipsum" - } - """ diff --git a/features/main/standard_put.feature b/features/main/standard_put.feature deleted file mode 100644 index 670ab6d0d0b..00000000000 --- a/features/main/standard_put.feature +++ /dev/null @@ -1,148 +0,0 @@ -Feature: Spec-compliant PUT support - As a client software developer - I need to be able to create or replace resources using the PUT HTTP method - - @createSchema - Scenario: Create a new resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/5" with body: - """ - { - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/StandardPut", - "@id": "/standard_puts/5", - "@type": "StandardPut", - "id": 5, - "foo": "a", - "bar": "b" - } - """ - - Scenario: Create a new resource with JSON-LD attributes - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/6" with body: - """ - { - "@id": "/standard_puts/6", - "@context": "/contexts/StandardPut", - "@type": "StandardPut", - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/StandardPut", - "@id": "/standard_puts/6", - "@type": "StandardPut", - "id": 6, - "foo": "a", - "bar": "b" - } - """ - - Scenario: Fails to create a new resource with the wrong JSON-LD @id - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/7" with body: - """ - { - "@id": "/dummies/6", - "@context": "/contexts/StandardPut", - "@type": "StandardPut", - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 400 - - Scenario: Fails to create a new resource when the JSON-LD @id doesn't match the URI - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/7" with body: - """ - { - "@id": "/standard_puts/6", - "@context": "/contexts/StandardPut", - "@type": "StandardPut", - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 400 - - Scenario: Replace an existing resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/5" with body: - """ - { - "foo": "c" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/StandardPut", - "@id": "/standard_puts/5", - "@type": "StandardPut", - "id": 5, - "foo": "c", - "bar": "" - } - """ - - @createSchema - @!mongodb - Scenario: Create a new resource identified by an uid - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335" with body: - """ - { - "name": "test" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/UidIdentified", - "@id": "/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335", - "@type": "UidIdentified", - "id": "fbcf5910-d915-4f7d-ba39-6b2957c57335", - "name": "test" - } - """ - - @!mongodb - Scenario: Replace an existing resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335" with body: - """ - { - "name": "bar" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/UidIdentified", - "@id": "/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335", - "@type": "UidIdentified", - "id": "fbcf5910-d915-4f7d-ba39-6b2957c57335", - "name": "bar" - } - """ diff --git a/features/main/sub_resource.feature b/features/main/sub_resource.feature deleted file mode 100644 index 1a8b9e14ad1..00000000000 --- a/features/main/sub_resource.feature +++ /dev/null @@ -1,633 +0,0 @@ -Feature: Sub-resource support - In order to use a hypermedia API - As a client software developer - I need to be able to retrieve embedded resources only as resources - - @createSchema - Scenario: Get sub-resource one to one relation - Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?" - When I send a "GET" request to "/questions/1/answer" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/Answer", - "@id": "/questions/1/answer", - "@type": "Answer", - "id": 1, - "content": "42", - "question": "/questions/1", - "relatedQuestions": [ - "/questions/1" - ] - } - """ - - @createSchema - Scenario: Get a non existent sub-resource - Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?" - When I send a "GET" request to "/questions/999999/answer" - Then the response status code should be 404 - And the response should be in JSON - - @createSchema - Scenario: Get recursive sub-resource one to many relation - Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?" - When I send a "GET" request to "/questions/1/answer/related_questions" - And the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/Question", - "@id": "/questions/1/answer/related_questions", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/questions/1", - "@type": "Question", - "content": "What's the answer to the Ultimate Question of Life, the Universe and Everything?", - "id": 1, - "answer": "/answers/1" - } - ], - "hydra:totalItems": 1 - } - """ - - @createSchema - Scenario: Get the sub-resource relation collection - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies/1/related_dummies" - 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/RelatedDummy", - "@id": "/dummies/1/related_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "id": 1, - "name": "Hello", - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - }, - { - "@id": "/related_dummies/2", - "@type": "https://schema.org/Product", - "id": 2, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - ], - "hydra:totalItems": 2, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummies/1/related_dummies{?relatedToDummyFriend.dummyFriend,relatedToDummyFriend.dummyFriend[],name,age,age[],id,id[],symfony,symfony[],dummyDate[before],dummyDate[strictly_before],dummyDate[after],dummyDate[strictly_after]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "relatedToDummyFriend.dummyFriend", - "property": "relatedToDummyFriend.dummyFriend", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedToDummyFriend.dummyFriend[]", - "property": "relatedToDummyFriend.dummyFriend", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "age", - "property": "age", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "age[]", - "property": "age", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id[]", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "symfony", - "property": "symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "symfony[]", - "property": "symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[after]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_after]", - "property": "dummyDate", - "required": false - } - ] - } - } - """ - - @createSchema - Scenario: Get filtered embedded relation sub-resource collection - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies/1/related_dummies?name=Hello" - 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/RelatedDummy", - "@id": "/dummies/1/related_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "id": 1, - "name": "Hello", - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - ], - "hydra:totalItems": 1, - "hydra:view": { - "@id": "/dummies/1/related_dummies?name=Hello", - "@type": "hydra:PartialCollectionView" - }, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummies/1/related_dummies{?relatedToDummyFriend.dummyFriend,relatedToDummyFriend.dummyFriend[],name,age,age[],id,id[],symfony,symfony[],dummyDate[before],dummyDate[strictly_before],dummyDate[after],dummyDate[strictly_after]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "relatedToDummyFriend.dummyFriend", - "property": "relatedToDummyFriend.dummyFriend", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedToDummyFriend.dummyFriend[]", - "property": "relatedToDummyFriend.dummyFriend", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "age", - "property": "age", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "age[]", - "property": "age", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id[]", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "symfony", - "property": "symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "symfony[]", - "property": "symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[after]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_after]", - "property": "dummyDate", - "required": false - } - ] - } - } - """ - - @createSchema - Scenario: Get the sub-resource relation item - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies/1/related_dummies/2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedDummy", - "@id": "/dummies/1/related_dummies/2", - "@type": "https://schema.org/Product", - "id": 2, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - """ - - Scenario: Create a dummy with a relation that is a sub-resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Dummy with relations", - "relatedDummy": "/dummies/1/related_dummies/2" - } - """ - 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" - - Scenario: Get the embedded relation sub-resource item at the third level - When I send a "GET" request to "/dummies/1/related_dummies/1/third_level" - And 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/ThirdLevel", - "@id": "/dummies/1/related_dummies/1/third_level", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1", - "badFourthLevel": null, - "id": 1, - "level": 3, - "test": true, - "relatedDummies": [ - "/related_dummies/1", - "/related_dummies/2" - ] - } - """ - - Scenario: Get the embedded relation sub-resource item at the fourth level - When I send a "GET" request to "/dummies/1/related_dummies/1/third_level/fourth_level" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/FourthLevel", - "@id": "/dummies/1/related_dummies/1/third_level/fourth_level", - "@type": "FourthLevel", - "badThirdLevel": [], - "id": 1, - "level": 4 - } - """ - - @createSchema - Scenario: Get offers sub-resource from aggregate offers sub-resource - Given I have a product with offers - When I send a "GET" request to "/dummy_products/2/offers/1/offers" - 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/DummyOffer", - "@id": "/dummy_products/2/offers/1/offers", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy_offers/1", - "@type": "DummyOffer", - "id": 1, - "value": 2, - "aggregate": "/dummy_aggregate_offers/1" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Get offers sub-resource from aggregate offers sub-resource - When I send a "GET" request to "/dummy_aggregate_offers/1/offers" - 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/DummyOffer", - "@id": "/dummy_aggregate_offers/1/offers", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy_offers/1", - "@type": "DummyOffer", - "id": 1, - "value": 2, - "aggregate": "/dummy_aggregate_offers/1" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: The recipient of the person's greetings should be empty - Given there is a person named "Alice" greeting with a "hello" message - When I send a "GET" request to "/people/1/sent_greetings" - 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/Greeting", - "@id": "/people/1/sent_greetings", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/greetings/1", - "@type": "Greeting", - "message": "hello", - "sender": "/people/1", - "recipient": null, - "id": 1 - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Recursive resource - When I send a "GET" request to "/dummy_products/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/DummyProduct", - "@id": "/dummy_products/2", - "@type": "DummyProduct", - "offers": [ - "/dummy_aggregate_offers/1" - ], - "id": 2, - "name": "Dummy product", - "relatedProducts": [ - "/dummy_products/1" - ], - "parent": null - } - """ - - @createSchema - Scenario: The OneToOne sub-resource should be accessible from owned side - Given there is a RelatedOwnedDummy object with OneToOne relation - When I send a "GET" request to "/related_owned_dummies/1/owning_dummy" - 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/Dummy", - "@id": "/related_owned_dummies/1/owning_dummy", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": "/related_owned_dummies/1", - "relatedOwningDummy": null, - "id": 1, - "name": "plop", - "alias": null, - "foo": null - } - """ - - @createSchema - Scenario: The OneToOne sub-resource should be accessible from owning side - Given there is a RelatedOwningDummy object with OneToOne relation - When I send a "GET" request to "/related_owning_dummies/1/owned_dummy" - 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/Dummy", - "@id": "/related_owning_dummies/1/owned_dummy", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": "/related_owning_dummies/1", - "id": 1, - "name": "plop", - "alias": null, - "foo": null - } - """ - - @!mongodb - @createSchema - Scenario Outline: The generated crud should allow us to interact with the subresources - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/subresource_organizations" with body: - """ - { - "name": "Les Tilleuls" - } - """ - Then the response status code should be 201 - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "" with body: - """ - { - "name": "soyuka" - } - """ - Then the response status code should be 404 - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "" with body: - """ - { - "name": "soyuka" - } - """ - Then the response status code should be 201 - And I send a "GET" request to "" - Then the response status code should be 200 - And I send a "GET" request to "" - Then the response status code should be 200 - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "" with body: - """ - { - "name": "ok" - } - """ - Then the response status code should be 200 - Given I send a "DELETE" request to "" - Then the response status code should be 204 - Examples: - | invalid_uri | collection_uri | item_uri | - | /subresource_organizations/invalid/subresource_employees | /subresource_organizations/1/subresource_employees | /subresource_organizations/1/subresource_employees/1 | - | /subresource_organizations/invalid/subresource_factories | /subresource_organizations/1/subresource_factories | /subresource_organizations/1/subresource_factories/1 | - - @!mongodb - @createSchema - Scenario: I can POST on a subresource using CreateProvider with parent_uri_template - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/subresource_categories/1/subresource_bikes" with body: - """ - { - "name": "Hello World!" - } - """ - Then the response status code should be 404 - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/subresource_categories_with_create_provider/1/subresource_bikes" with body: - """ - { - "name": "Hello World!" - } - """ - Then the response status code should be 201 diff --git a/features/main/table_inheritance.feature b/features/main/table_inheritance.feature deleted file mode 100644 index 1c3617f5f92..00000000000 --- a/features/main/table_inheritance.feature +++ /dev/null @@ -1,798 +0,0 @@ -Feature: Table inheritance - In order to use the api with Doctrine table inheritance - As a client software developer - I need to be able to create resources and fetch them on the upper entity - - @createSchema - Scenario: Create a table inherited resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_table_inheritance_children" with body: - """ - {"name": "foo", "nickname": "bar"} - """ - 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 valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/1$" - }, - "name": { - "type": "string", - "pattern": "^foo$" - }, - "nickname": { - "type": "string", - "pattern": "^bar$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "name", - "nickname" - ] - } - """ - - Scenario: Get the parent entity collection - When I send a "GET" request to "/dummy_table_inheritances" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/1$" - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name", - "nickname" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "hydra:member" - ] - } - """ - - Scenario: Some children not api resources are created in the app - When some dummy table inheritance data but not api resource child are created - And I send a "GET" request to "/dummy_table_inheritances" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/1$" - }, - "name": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritance$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritances/2$" - }, - "name": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name" - ] - } - ], - "additionalItems": false - }, - "hydra:totalItems": { - "type": "integer", - "minimum": 2, - "maximum": 2 - } - }, - "required": [ - "hydra:member", - "hydra:totalItems" - ] - } - """ - - Scenario: Create a table inherited resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_table_inheritance_children" with body: - """ - {"name": "foo", "nickname": "bar"} - """ - 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 valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/3$" - }, - "name": { - "type": "string", - "pattern": "^foo$" - }, - "nickname": { - "type": "string", - "pattern": "^bar$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "name", - "nickname" - ] - } - """ - - Scenario: Create a different table inherited resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_table_inheritance_different_children" with body: - """ - {"name": "foo", "email": "bar@localhost"} - """ - Then the response status code should be 201 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceDifferentChild$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/DummyTableInheritanceDifferentChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_different_children/4$" - }, - "name": { - "type": "string", - "pattern": "^foo$" - }, - "email": { - "type": "string", - "pattern": "^bar\\@localhost$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "name", - "email" - ] - } - """ - - Scenario: Get related entity with multiple inherited children types - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_table_inheritance_relateds" with body: - """ - { - "children": [ - "/dummy_table_inheritance_children/1", - "/dummy_table_inheritance_different_children/4" - ] - } - """ - 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 valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceRelated$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/DummyTableInheritanceRelated$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_relateds/1$" - }, - "children": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - }, - "required": [ - "@type", - "name", - "nickname" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceDifferentChild$" - }, - "name": { - "type": "string" - }, - "email": { - "type": "string" - } - }, - "required": [ - "@type", - "name", - "email" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "@type", - "@context", - "@id", - "children" - ] - } - """ - - Scenario: Get the parent entity collection which contains multiple inherited children type - When I send a "GET" request to "/dummy_table_inheritances" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/1$" - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name", - "nickname" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritance$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritances/2$" - }, - "name": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/3$" - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name", - "nickname" - ] - } - ], - "additionalItems": false - }, - "hydra:totalItems": { - "type": "integer", - "minimum": 4, - "maximum": 4 - } - }, - "required": [ - "hydra:member", - "hydra:totalItems" - ] - } - """ - - Scenario: Get the parent interface collection - When I send a "GET" request to "/resource_interfaces" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^ResourceInterface$" - }, - "@id": { - "type": "string", - "pattern": "^/resource_interfaces/item1" - }, - "foo": { - "type": "string", - "pattern": "^item1$" - }, - "fooz": { - "type": "string", - "pattern": "^fooz$" - } - }, - "required": [ - "@type", - "@id", - "foo", - "fooz" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^ResourceInterface$" - }, - "@id": { - "type": "string", - "pattern": "^/resource_interfaces/item2" - }, - "foo": { - "type": "string", - "pattern": "^item2$" - }, - "fooz": { - "type": "string", - "pattern": "^fooz$" - } - }, - "required": [ - "@type", - "@id", - "foo", - "fooz" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "hydra:member" - ] - } - """ - - Scenario: Get an interface resource item - When I send a "GET" request to "/resource_interfaces/some-id" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": { - "type": "string", - "pattern": "^/contexts/ResourceInterface$" - }, - "@id": { - "type": "string", - "pattern": "^/resource_interfaces/single%20item$" - }, - "@type": { - "type": "string", - "pattern": "^ResourceInterface$" - }, - "foo": { - "type": "string", - "pattern": "^single item$" - }, - "fooz": { - "type": "string", - "pattern": "fooz" - } - }, - "required": [ - "@context", - "@id", - "@type", - "foo", - "fooz" - ], - "additionalProperties": false - } - """ - - @!mongodb - Scenario: Generate iri from parent resource - Given there are 3 sites with internal owner - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/sites" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/1$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/custom_users/1$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/2$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/custom_users/2$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/3$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/custom_users/3$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "hydra:member" - ] - } - """ - - @!mongodb - @createSchema - Scenario: Generate iri from current resource even if parent class is a resource - Given there are 3 sites with external owner - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/sites" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/1$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/external_users/1$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/2$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/external_users/2$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/3$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/external_users/3$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "hydra:member" - ] - } - """ diff --git a/features/main/union_intersect_types.feature b/features/main/union_intersect_types.feature deleted file mode 100644 index 73195804d38..00000000000 --- a/features/main/union_intersect_types.feature +++ /dev/null @@ -1,121 +0,0 @@ -Feature: Union/Intersect types - - Scenario Outline: Create a resource with union type - When I add "Content-Type" header equal to "application/ld+json" - And I add "Accept" header equal to "application/ld+json" - And I send a "POST" request to "/issue-5452/books" with body: - """ - { - "number": , - "isbn": "978-3-16-148410-0" - } - """ - 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 valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Book$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/Book$" - }, - "@id": { - "type": "string", - "pattern": "^/.well-known/genid/.+$" - }, - "number": { - "type": "" - }, - "isbn": { - "type": "string", - "pattern": "^978-3-16-148410-0$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "number", - "isbn" - ] - } - """ - Examples: - | number | type | - | "1" | string | - | 1 | integer | - - Scenario: Create a resource with valid intersect type - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue-5452/books" with body: - """ - { - "number": 1, - "isbn": "978-3-16-148410-0", - "author": "/issue-5452/authors/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 valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Book$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/Book$" - }, - "@id": { - "type": "string", - "pattern": "^/.well-known/genid/.+$" - }, - "number": { - "type": "integer" - }, - "isbn": { - "type": "string", - "pattern": "^978-3-16-148410-0$" - }, - "author": { - "type": "string", - "pattern": "^/issue-5452/authors/1$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "number", - "isbn", - "author" - ] - } - """ - - Scenario: Create a resource with invalid intersect type - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue-5452/books" with body: - """ - { - "number": 1, - "isbn": "978-3-16-148410-0", - "library": "/issue-5452/libraries/1" - } - """ - Then the response status code should be 400 - 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 'Could not denormalize object of type "ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface", no supporting normalizer found.' diff --git a/features/main/url_encoded_id.feature b/features/main/url_encoded_id.feature deleted file mode 100644 index 6e5828bbab3..00000000000 --- a/features/main/url_encoded_id.feature +++ /dev/null @@ -1,26 +0,0 @@ -Feature: Allowing resource identifiers with characters that should be URL encoded - In order to have a resource with an id with special characters - As a client software developer - I need to be able to set and retrieve these resources with the URL encoded ID - - @createSchema - Scenario Outline: Get a resource whether or not the id is URL encoded - Given there is a UrlEncodedId resource - And I add "Content-Type" header equal to "application/ld+json" - When I send a "GET" request to "" - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/UrlEncodedId", - "@id": "/url_encoded_ids/%25encode:id", - "@type": "UrlEncodedId", - "id": "%encode:id" - } - """ - Examples: - | url | - | /url_encoded_ids/%encode:id | - | /url_encoded_ids/%25encode%3Aid | - | /url_encoded_ids/%25encode:id | - | /url_encoded_ids/%encode%3Aid | diff --git a/features/main/uuid.feature b/features/main/uuid.feature deleted file mode 100644 index a7506a15bec..00000000000 --- a/features/main/uuid.feature +++ /dev/null @@ -1,205 +0,0 @@ -Feature: Using uuid identifier on resource - In order to use an hypermedia API - As a client software developer - I need to be able to user other identifier than id in resource and set it via API call on POST / PUT. - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/uuid_identifier_dummies" with body: - """ - { - "name": "My Dummy", - "uuid": "41b29566-144b-11e6-a148-3e1d05defe78" - } - """ - 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 header "Content-Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78.jsonld" - And the header "Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" - - Scenario: Get a resource - When I send a "GET" request to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" - 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/UuidIdentifierDummy", - "@id": "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78", - "@type": "UuidIdentifierDummy", - "uuid": "41b29566-144b-11e6-a148-3e1d05defe78", - "name": "My Dummy" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/uuid_identifier_dummies" - 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/UuidIdentifierDummy", - "@id": "/uuid_identifier_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78", - "@type": "UuidIdentifierDummy", - "uuid": "41b29566-144b-11e6-a148-3e1d05defe78", - "name": "My Dummy" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" with body: - """ - { - "name": "My Dummy modified" - } - """ - 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 header "Content-Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/UuidIdentifierDummy", - "@id": "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78", - "@type": "UuidIdentifierDummy", - "uuid": "41b29566-144b-11e6-a148-3e1d05defe78", - "name": "My Dummy modified" - } - """ - - Scenario: Create a resource with custom id generator - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/custom_generated_identifiers" with body: - """ - {} - """ - 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 header "Content-Location" should be equal to "/custom_generated_identifiers/foo.jsonld" - And the header "Location" should be equal to "/custom_generated_identifiers/foo" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomGeneratedIdentifier", - "@id": "/custom_generated_identifiers/foo", - "@type": "CustomGeneratedIdentifier", - "id": "foo" - } - """ - - Scenario: Delete a resource - When I send a "DELETE" request to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" - Then the response status code should be 204 - And the response should be empty - - @!mongodb - @createSchema - Scenario: Retrieve a resource identified by Ramsey\Uuid\Uuid - Given there is a ramsey identified resource with uuid "41B29566-144B-11E6-A148-3E1D05DEFE78" - When I send a "GET" request to "/ramsey_uuid_dummies/41B29566-144B-11E6-A148-3E1D05DEFE78" - 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: Delete a resource identified by a Ramsey\Uuid\Uuid - When I send a "DELETE" request to "/ramsey_uuid_dummies/41B29566-144B-11E6-A148-3E1D05DEFE78" - Then the response status code should be 204 - And the response should be empty - - @!mongodb - Scenario: Retrieve a resource identified by a bad Ramsey\Uuid\Uuid - When I send a "GET" request to "/ramsey_uuid_dummies/41B29566-144B-E1D05DEFE78" - 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" - - @!mongodb - @createSchema - Scenario: Create a resource identified by Ramsey\Uuid\Uuid - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/ramsey_uuid_dummies" with body: - """ - { - "id": "41b29566-144b-11e6-a148-3e1d05defe78" - } - """ - 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" - - @!mongodb - Scenario: Create a resource with a Ramsey\Uuid\Uuid non-id field - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/ramsey_uuid_dummies" with body: - """ - { - "other": "51b29566-144b-11e6-a148-3e1d05defe78" - } - """ - 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" - - @!mongodb - Scenario: Update a resource with a Ramsey\Uuid\Uuid non-id field - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/ramsey_uuid_dummies/41b29566-144b-11e6-a148-3e1d05defe78" with body: - """ - { - "other": "61b29566-144b-11e6-a148-3e1d05defe78" - } - """ - 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 a resource identified by a bad Ramsey\Uuid\Uuid - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/ramsey_uuid_dummies" with body: - """ - { - "id": "41b29566-144b-e1d05defe78" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - Scenario: Update a resource with a bad Ramsey\Uuid\Uuid non-id field - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/ramsey_uuid_dummies/41b29566-144b-11e6-a148-3e1d05defe78" with body: - """ - { - "other": "61b29566-144b-e1d05defe78" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - @createSchema - Scenario: Retrieve a resource identified by Symfony\Component\Uid\Uuid - Given there is a Symfony dummy identified resource with uuid "cdf8f706-ebe3-4fb6-b0bd-ae7b48028f24" - When I send a "GET" request to "/symfony_uuid_dummies/cdf8f706-ebe3-4fb6-b0bd-ae7b48028f24" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/features/main/validation.feature b/features/main/validation.feature deleted file mode 100644 index 40e22bdfb3a..00000000000 --- a/features/main/validation.feature +++ /dev/null @@ -1,120 +0,0 @@ -Feature: Using validations groups - As a client software developer - I need to be able to use validation groups - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_validation" with body: - """ - { - "code": "My Dummy" - } - """ - 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" - - @createSchema - Scenario: Create a resource with validation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_validation/validation_groups" with body: - """ - { - "code": "My Dummy" - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON should be a superset of: - """ - { - "@context": "/contexts/ConstraintViolation", - "@type": "ConstraintViolation", - "detail": "name: This value should not be null.", - "violations": [ - { - "propertyPath": "name", - "message": "This value should not be null.", - "code": "ad32d13f-c3d4-423b-909a-857b961eb720" - } - ] - } - """ - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @createSchema - Scenario: Create a resource with validation group sequence - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_validation/validation_sequence" with body: - """ - { - "code": "My Dummy" - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON should be a superset of: - """ - { - "@context": "/contexts/ConstraintViolation", - "@type": "ConstraintViolation", - "detail": "title: This value should not be null.", - "violations": [ - { - "propertyPath": "title", - "message": "This value should not be null.", - "code": "ad32d13f-c3d4-423b-909a-857b961eb720" - } - ] - } - """ - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @createSchema - Scenario: Create a resource with serializedName property - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "dummy_validation_serialized_name" with body: - """ - { - "code": "My Dummy" - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON node "violations[0].message" should be equal to "This value should not be null." - And the JSON node "violations[0].propertyPath" should be equal to "test" - And the JSON node "detail" should be equal to "test: This value should not be null." - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @createSchema - @!mongodb - Scenario: Get violations constraints - When I add "Accept" header equal to "application/json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/issue5912s" with body: - """ - { - "title": "" - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "status": 422, - "violations": [ - { - "propertyPath": "title", - "message": "This value should not be blank.", - "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" - } - ], - "detail": "title: This value should not be blank.", - "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", - "title": "An error occurred" - } - """ - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - diff --git a/phpunit.baseline.xml b/phpunit.baseline.xml index 342ad126c6b..ebb80a94bd9 100644 --- a/phpunit.baseline.xml +++ b/phpunit.baseline.xml @@ -43,6 +43,9 @@ + + + diff --git a/tests/Fixtures/TestBundle/Entity/Answer.php b/tests/Fixtures/TestBundle/Entity/Answer.php index 0eda5f4063a..250ca65a6be 100644 --- a/tests/Fixtures/TestBundle/Entity/Answer.php +++ b/tests/Fixtures/TestBundle/Entity/Answer.php @@ -42,9 +42,6 @@ class Answer #[ORM\Column(nullable: false)] #[Serializer\Groups(['foobar'])] private ?string $content = null; - #[ORM\OneToOne(targetEntity: Question::class, mappedBy: 'answer')] - #[Serializer\Groups(['foobar'])] - private ?Question $question = null; /** * @var Collection */ @@ -85,24 +82,6 @@ public function getContent(): ?string return $this->content; } - /** - * Set question. - */ - public function setQuestion(?Question $question = null): self - { - $this->question = $question; - - return $this; - } - - /** - * Get question. - */ - public function getQuestion(): ?Question - { - return $this->question; - } - /** * Get related question. */ diff --git a/tests/Fixtures/TestBundle/Entity/FourthLevel.php b/tests/Fixtures/TestBundle/Entity/FourthLevel.php index cb0313b5dec..c85935e8601 100644 --- a/tests/Fixtures/TestBundle/Entity/FourthLevel.php +++ b/tests/Fixtures/TestBundle/Entity/FourthLevel.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Link; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -46,7 +47,12 @@ class FourthLevel #[Groups(['barcelona', 'chicago'])] private int $level = 4; #[ORM\OneToMany(targetEntity: ThirdLevel::class, cascade: ['persist'], mappedBy: 'badFourthLevel')] - public Collection|iterable|null $badThirdLevel = null; + public Collection|iterable $badThirdLevel; + + public function __construct() + { + $this->badThirdLevel = new ArrayCollection(); + } public function getId(): ?int { diff --git a/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceAnswer.php b/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceAnswer.php new file mode 100644 index 00000000000..32f2f511bc1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceAnswer.php @@ -0,0 +1,70 @@ + + * + * 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\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ApiResource( + uriTemplate: '/one_to_one_subresource_questions/{id}/answer{._format}', + uriVariables: ['id' => new Link(fromClass: OneToOneSubresourceQuestion::class, identifiers: ['id'], fromProperty: 'answer')], + status: 200, + operations: [new Get()] +)] +#[ORM\Entity] +class OneToOneSubresourceAnswer +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: false)] + private ?string $content = null; + + #[ORM\OneToOne(targetEntity: OneToOneSubresourceQuestion::class, mappedBy: 'answer')] + private ?OneToOneSubresourceQuestion $question = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + + return $this; + } + + public function getQuestion(): ?OneToOneSubresourceQuestion + { + return $this->question; + } + + public function setQuestion(?OneToOneSubresourceQuestion $question): self + { + $this->question = $question; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceQuestion.php b/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceQuestion.php new file mode 100644 index 00000000000..306027a6756 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceQuestion.php @@ -0,0 +1,63 @@ + + * + * 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\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity] +class OneToOneSubresourceQuestion +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: true)] + private ?string $content = null; + + #[ORM\OneToOne(targetEntity: OneToOneSubresourceAnswer::class, inversedBy: 'question', cascade: ['persist'])] + #[ORM\JoinColumn(name: 'answer_id', referencedColumnName: 'id')] + private ?OneToOneSubresourceAnswer $answer = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + + return $this; + } + + public function getAnswer(): ?OneToOneSubresourceAnswer + { + return $this->answer; + } + + public function setAnswer(?OneToOneSubresourceAnswer $answer): self + { + $this->answer = $answer; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Question.php b/tests/Fixtures/TestBundle/Entity/Question.php index aa1b82cd74e..de56210af04 100644 --- a/tests/Fixtures/TestBundle/Entity/Question.php +++ b/tests/Fixtures/TestBundle/Entity/Question.php @@ -30,8 +30,8 @@ class Question private ?int $id = null; #[ORM\Column(nullable: true)] private ?string $content = null; - #[ORM\OneToOne(targetEntity: Answer::class, inversedBy: 'question')] - #[ORM\JoinColumn(name: 'answer_id', referencedColumnName: 'id', unique: true)] + #[ORM\ManyToOne(targetEntity: Answer::class, inversedBy: 'relatedQuestions')] + #[ORM\JoinColumn(name: 'answer_id', referencedColumnName: 'id')] private ?Answer $answer = null; /** diff --git a/tests/Functional/AttributeResourceTest.php b/tests/Functional/AttributeResourceTest.php new file mode 100644 index 00000000000..9c959ff4044 --- /dev/null +++ b/tests/Functional/AttributeResourceTest.php @@ -0,0 +1,151 @@ + + * + * 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\ApiResource\PostWithUriVariables; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AttributeResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AttributeResources; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IncompleteUriVariableConfigured; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AttributeResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [AttributeResource::class, AttributeResources::class, IncompleteUriVariableConfigured::class, PostWithUriVariables::class, Dummy::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB() || $this->isMysql()) { + $this->markTestSkipped(); + } + } + + public function testGetAttributeResourcesCollection(): void + { + self::createClient()->request('GET', '/attribute_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/AttributeResources', + '@id' => '/attribute_resources', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + ['@id' => '/attribute_resources/1', '@type' => 'AttributeResource', 'identifier' => 1, 'name' => 'Foo'], + ['@id' => '/attribute_resources/2', '@type' => 'AttributeResource', 'identifier' => 2, 'name' => 'Bar'], + ], + ]); + } + + public function testGetAttributeResourceItem(): void + { + self::createClient()->request('GET', '/attribute_resources/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/AttributeResource', + '@id' => '/attribute_resources/1', + '@type' => 'AttributeResource', + 'identifier' => 1, + 'name' => 'Foo', + ]); + } + + public function testAliasedResourceRedirectsAndShowsTarget(): void + { + self::createClient()->request('GET', '/dummy/1/attribute_resources/2', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(301); + $this->assertResponseHeaderSame('Location', '/attribute_resources/2'); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/AttributeResource', + '@id' => '/attribute_resources/2', + '@type' => 'AttributeResource', + 'identifier' => 2, + 'dummy' => '/dummies/1', + 'name' => 'Foo', + ]); + } + + public function testPatchAliasedResource(): void + { + self::createClient()->request('PATCH', '/dummy/1/attribute_resources/2', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'Patched'], + ]); + + $this->assertResponseStatusCodeSame(301); + $this->assertResponseHeaderSame('Location', '/attribute_resources/2'); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/AttributeResource', + '@id' => '/attribute_resources/2', + '@type' => 'AttributeResource', + 'identifier' => 2, + 'dummy' => '/dummies/1', + 'name' => 'Patched', + ]); + } + + public function testIncompleteUriVariableConfigurationProducesProblem(): void + { + $response = self::createClient()->request('GET', '/photos/1/resize/300/100'); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $linkHeader = $response->getHeaders(false)['link'][0] ?? ''; + $this->assertStringContainsString('; rel="http://www.w3.org/ns/json-ld#error"', $linkHeader); + $this->assertJsonContains(['detail' => 'Unable to generate an IRI for the item of type "ApiPlatform\\Tests\\Fixtures\\TestBundle\\Entity\\IncompleteUriVariableConfigured"']); + } + + public function testPostWithUriVariablesAndNoProvider(): void + { + self::createClient()->request('POST', '/post_with_uri_variables_and_no_provider/{id}', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(201); + } + + public function testProviderThrowsValidationException(): void + { + self::createClient()->request('POST', '/post_with_uri_variables/{id}', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(422); + } +} diff --git a/tests/Functional/CircularReferenceTest.php b/tests/Functional/CircularReferenceTest.php new file mode 100644 index 00000000000..5dfc1e33557 --- /dev/null +++ b/tests/Functional/CircularReferenceTest.php @@ -0,0 +1,111 @@ + + * + * 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\CircularReference; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CircularReferenceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CircularReference::class]; + } + + protected function setUp(): void + { + $this->recreateSchema($this->getResources()); + } + + public function testSelfReferencingCircularReference(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + + $client->request('POST', '/circular_references', ['headers' => $headers, 'json' => new \stdClass()]); + $client->request('PUT', '/circular_references/1', [ + 'headers' => $headers, + 'json' => ['parent' => '/circular_references/1'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CircularReference', + '@id' => '/circular_references/1', + '@type' => 'CircularReference', + 'parent' => '/circular_references/1', + 'children' => ['/circular_references/1'], + ]); + } + + public function testFetchCircularReferenceWithParentSibling(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + + $client->request('POST', '/circular_references', ['headers' => $headers, 'json' => new \stdClass()]); + $client->request('POST', '/circular_references', ['headers' => $headers, 'json' => new \stdClass()]); + $client->request('PUT', '/circular_references/1', [ + 'headers' => $headers, + 'json' => ['parent' => '/circular_references/1'], + ]); + $client->request('PUT', '/circular_references/2', [ + 'headers' => $headers, + 'json' => ['parent' => '/circular_references/1'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CircularReference', + '@id' => '/circular_references/2', + '@type' => 'CircularReference', + 'parent' => [ + '@id' => '/circular_references/1', + '@type' => 'CircularReference', + 'parent' => '/circular_references/1', + 'children' => ['/circular_references/1', '/circular_references/2'], + ], + 'children' => [], + ]); + + $client->request('GET', '/circular_references/1'); + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CircularReference', + '@id' => '/circular_references/1', + '@type' => 'CircularReference', + 'parent' => '/circular_references/1', + 'children' => [ + '/circular_references/1', + [ + '@id' => '/circular_references/2', + '@type' => 'CircularReference', + 'parent' => '/circular_references/1', + 'children' => [], + ], + ], + ]); + } +} diff --git a/tests/Functional/CompositeIdentifierTest.php b/tests/Functional/CompositeIdentifierTest.php new file mode 100644 index 00000000000..1e7617ecdf4 --- /dev/null +++ b/tests/Functional/CompositeIdentifierTest.php @@ -0,0 +1,170 @@ + + * + * 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\ApiResource\Issue5396\CompositeKeyWithDifferentType; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CompositeIdentifierTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CompositeItem::class, CompositeLabel::class, CompositeRelation::class, CompositeKeyWithDifferentType::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([CompositeItem::class, CompositeLabel::class, CompositeRelation::class]); + $this->seedComposite(); + } + + private function seedComposite(): void + { + $manager = $this->getManager(); + $item = new CompositeItem(); + $item->setField1('foobar'); + $manager->persist($item); + $manager->flush(); + + for ($i = 0; $i < 4; ++$i) { + $label = new CompositeLabel(); + $label->setValue('foo-'.$i); + $manager->persist($label); + $manager->flush(); + + $rel = new CompositeRelation(); + $rel->setCompositeLabel($label); + $rel->setCompositeItem($item); + $rel->setValue('somefoobardummy'); + $manager->persist($rel); + } + $manager->flush(); + $manager->clear(); + } + + public function testCollectionWithCompositeIdentifiers(): void + { + self::createClient()->request('GET', '/composite_items'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CompositeItem', + '@id' => '/composite_items', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/composite_items/1', + '@type' => 'CompositeItem', + 'id' => 1, + 'field1' => 'foobar', + 'compositeValues' => [ + '/composite_relations/compositeItem=1;compositeLabel=1', + '/composite_relations/compositeItem=1;compositeLabel=2', + '/composite_relations/compositeItem=1;compositeLabel=3', + '/composite_relations/compositeItem=1;compositeLabel=4', + ], + ], + ], + 'hydra:totalItems' => 1, + ]); + } + + public function testCollectionOfCompositeRelations(): void + { + self::createClient()->request('GET', '/composite_relations'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/CompositeRelation', + '@id' => '/composite_relations', + '@type' => 'hydra:Collection', + 'hydra:totalItems' => 4, + 'hydra:view' => [ + '@id' => '/composite_relations?page=1', + '@type' => 'hydra:PartialCollectionView', + 'hydra:first' => '/composite_relations?page=1', + 'hydra:last' => '/composite_relations?page=2', + 'hydra:next' => '/composite_relations?page=2', + ], + ]); + } + + public function testGetCompositeRelationByCanonicalOrder(): void + { + self::createClient()->request('GET', '/composite_relations/compositeItem=1;compositeLabel=1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CompositeRelation', + '@id' => '/composite_relations/compositeItem=1;compositeLabel=1', + '@type' => 'CompositeRelation', + 'value' => 'somefoobardummy', + 'compositeItem' => '/composite_items/1', + 'compositeLabel' => '/composite_labels/1', + ]); + } + + public function testGetCompositeRelationByReverseOrder(): void + { + self::createClient()->request('GET', '/composite_relations/compositeLabel=1;compositeItem=1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@id' => '/composite_relations/compositeItem=1;compositeLabel=1', + '@type' => 'CompositeRelation', + ]); + } + + public function testMissingCompositeIdentifierReturns404(): void + { + self::createClient()->request('GET', '/composite_relations/compositeLabel=1;'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetCompositeItem(): void + { + self::createClient()->request('GET', '/composite_items/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testCompositeIdentifierWithDifferentTypes(): void + { + self::createClient()->request('GET', '/composite_key_with_different_types/id=82133;verificationKey=7d75af772e637e45c36d041696e1128d'); + + $this->assertResponseStatusCodeSame(200); + } +} diff --git a/tests/Functional/ConfigurableTest.php b/tests/Functional/ConfigurableTest.php new file mode 100644 index 00000000000..674beae78a4 --- /dev/null +++ b/tests/Functional/ConfigurableTest.php @@ -0,0 +1,113 @@ + + * + * 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\FileConfigDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SingleFileConfigDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ConfigurableTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FileConfigDummy::class, SingleFileConfigDummy::class]; + } + + private function seedFileConfigDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FileConfigDummy fixtures are ORM-only.'); + } + + $this->recreateSchema($this->getResources()); + + $manager = $this->getManager(); + $entity = new FileConfigDummy(); + $entity->setName('ConfigDummy'); + $entity->setFoo('Foo'); + $manager->persist($entity); + $manager->flush(); + $manager->clear(); + } + + public function testCollectionOfFileConfigDummies(): void + { + $this->seedFileConfigDummy(); + + self::createClient()->request('GET', '/fileconfigdummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/fileconfigdummy', + '@id' => '/fileconfigdummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/fileconfigdummies/1', + '@type' => 'fileconfigdummy', + 'id' => 1, + 'name' => 'ConfigDummy', + 'foo' => 'Foo', + ], + ], + 'hydra:totalItems' => 1, + ]); + } + + public function testCollectionOfSingleFileConfig(): void + { + $this->recreateSchema($this->getResources()); + + self::createClient()->request('GET', '/single_file_configs'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/single_file_config', + '@id' => '/single_file_configs', + '@type' => 'hydra:Collection', + 'hydra:member' => [], + 'hydra:totalItems' => 0, + ]); + } + + public function testFileConfigDummyItem(): void + { + $this->seedFileConfigDummy(); + + self::createClient()->request('GET', '/fileconfigdummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/fileconfigdummy', + '@id' => '/fileconfigdummies/1', + '@type' => 'fileconfigdummy', + 'id' => 1, + 'name' => 'ConfigDummy', + 'foo' => 'Foo', + ]); + } +} diff --git a/tests/Functional/ContentNegotiationTest.php b/tests/Functional/ContentNegotiationTest.php new file mode 100644 index 00000000000..d159aa24129 --- /dev/null +++ b/tests/Functional/ContentNegotiationTest.php @@ -0,0 +1,244 @@ + + * + * 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\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomFormat; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ContentNegotiationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class, DummyCustomFormat::class, SecuredDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class, DummyCustomFormat::class, SecuredDummy::class]); + } + + private function createDummyViaXml(): void + { + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'application/xml'], + 'body' => "\n XML!\n", + ]); + } + + public function testPostXmlBody(): void + { + $response = self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'application/xml'], + 'body' => "\n XML!\n", + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('XML!', $response->getContent()); + $this->assertStringContainsString('1', $response->getContent()); + } + + public function testRetrieveCollectionInXml(): void + { + $this->createDummyViaXml(); + + $response = self::createClient()->request('GET', '/dummies', [ + 'headers' => ['Accept' => 'text/xml'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('', $response->getContent()); + $this->assertStringContainsString('XML!', $response->getContent()); + } + + public function testRetrieveCollectionInXmlViaUrlSuffix(): void + { + $this->createDummyViaXml(); + + $response = self::createClient()->request('GET', '/dummies.xml', [ + 'headers' => ['Accept' => '*/*'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('XML!', $response->getContent()); + } + + public function testRetrieveCollectionInJson(): void + { + $this->createDummyViaXml(); + + $response = self::createClient()->request('GET', '/dummies', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/json; charset=utf-8'); + $data = $response->toArray(); + $this->assertIsArray($data); + $this->assertCount(1, $data); + $this->assertSame('XML!', $data[0]['name']); + $this->assertSame(1, $data[0]['id']); + } + + public function testPostJsonAcceptXml(): void + { + $this->createDummyViaXml(); + + $response = self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'application/json'], + 'json' => ['name' => 'Sent in JSON'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('Sent in JSON', $response->getContent()); + $this->assertStringContainsString('2', $response->getContent()); + } + + public function testFormatNegotiatedViaUrlMatchesAccept(): void + { + $this->createDummyViaXml(); + + self::createClient()->request('GET', '/dummies/1.xml', [ + 'headers' => ['Accept' => 'text/xml'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public function testWildcardAcceptDefaultsToFirstFormat(): void + { + $this->createDummyViaXml(); + + self::createClient()->request('GET', '/dummies/1', [ + 'headers' => ['Accept' => '*/*'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testWildcardAcceptDefaultsToUrlFormat(): void + { + $this->createDummyViaXml(); + + self::createClient()->request('GET', '/dummies/1.xml', [ + 'headers' => ['Accept' => 'text/plain; charset=utf-8, */*'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public function testUnknownFormatReturns406(): void + { + $this->createDummyViaXml(); + + self::createClient()->request('GET', '/dummies/1', [ + 'headers' => ['Accept' => 'text/plain'], + ]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testHtmlAcceptReturnsHtmlError(): void + { + $response = self::createClient()->request('GET', '/dummies/666', [ + 'headers' => ['Accept' => 'text/html'], + ]); + + $this->assertResponseStatusCodeSame(404); + $contentType = $response->getHeaders(false)['content-type'][0] ?? ''; + $this->assertStringStartsWith('text/html', $contentType); + } + + public function testRemovedFormatReturns406(): void + { + self::createClient()->request('GET', '/dummy_custom_formats', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testPostCsvBodyOnCustomFormatResource(): void + { + $response = self::createClient()->request('POST', '/dummy_custom_formats', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'text/csv'], + 'body' => "name\nKevin\n", + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('Kevin', $response->getContent()); + $this->assertStringContainsString('1', $response->getContent()); + } + + public function testRetrieveCollectionInCsv(): void + { + self::createClient()->request('POST', '/dummy_custom_formats', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'text/csv'], + 'body' => "name\nKevin\n", + ]); + + $response = self::createClient()->request('GET', '/dummy_custom_formats', [ + 'headers' => ['Accept' => 'text/csv'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'text/csv; charset=utf-8'); + $this->assertStringContainsString('id,name', $response->getContent()); + $this->assertStringContainsString('1,Kevin', $response->getContent()); + } + + public function testSecurityErrorInJson(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('SecuredDummy seed uses ORM Entity class.'); + } + + $manager = $this->getManager(); + $securedDummy = new SecuredDummy(); + $securedDummy->setTitle('#1'); + $securedDummy->setDescription('Hello #1'); + $securedDummy->setOwner('notexist'); + $manager->persist($securedDummy); + $manager->flush(); + + self::createClient()->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertResponseStatusCodeSame(401); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + $this->assertJsonEquals(['message' => 'Authentication Required']); + } +} diff --git a/tests/Functional/CrudAbstractTest.php b/tests/Functional/CrudAbstractTest.php new file mode 100644 index 00000000000..414ce6d2343 --- /dev/null +++ b/tests/Functional/CrudAbstractTest.php @@ -0,0 +1,171 @@ + + * + * 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\AbstractDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConcreteDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CrudAbstractTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [AbstractDummy::class, ConcreteDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([AbstractDummy::class, ConcreteDummy::class]); + } + + private function createConcrete(): void + { + self::createClient()->request('POST', '/concrete_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['instance' => 'Concrete', 'name' => 'My Dummy'], + ]); + } + + public function testCreateConcrete(): void + { + $this->createConcrete(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/concrete_dummies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/concrete_dummies/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Concrete', + 'id' => 1, + 'name' => 'My Dummy', + ]); + } + + public function testGetItemViaAbstractUri(): void + { + $this->createConcrete(); + + $response = self::createClient()->request('GET', '/abstract_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders())); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Concrete', + 'id' => 1, + 'name' => 'My Dummy', + ]); + } + + public function testGetCollectionViaAbstractUri(): void + { + $this->createConcrete(); + + $response = self::createClient()->request('GET', '/abstract_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertGreaterThanOrEqual(1, \count($data['hydra:member'])); + $this->assertSame('ConcreteDummy', $data['hydra:member'][0]['@type']); + $this->assertNotEmpty($data['hydra:member'][0]['instance']); + } + + public function testUpdateConcreteUri(): void + { + $this->createConcrete(); + + self::createClient()->request('PUT', '/concrete_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['@id' => '/concrete_dummies/1', 'instance' => 'Become real', 'name' => 'A nice dummy'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/concrete_dummies/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Become real', + 'id' => 1, + 'name' => 'A nice dummy', + ]); + } + + public function testUpdateConcreteViaAbstractUri(): void + { + $this->createConcrete(); + + self::createClient()->request('PUT', '/abstract_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['@id' => '/concrete_dummies/1', 'instance' => 'Become surreal', 'name' => 'A nicer dummy'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/concrete_dummies/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Become surreal', + 'id' => 1, + 'name' => 'A nicer dummy', + ]); + } + + public function testDeleteViaAbstractUri(): void + { + $this->createConcrete(); + + self::createClient()->request('DELETE', '/abstract_dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testCreateConcreteViaDiscriminatorOnAbstract(): void + { + self::createClient()->request('POST', '/abstract_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['discr' => 'concrete', 'instance' => 'Concrete', 'name' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Location', '/concrete_dummies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/concrete_dummies/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Concrete', + 'id' => 1, + 'name' => 'My Dummy', + ]); + } +} diff --git a/tests/Functional/CrudTest.php b/tests/Functional/CrudTest.php new file mode 100644 index 00000000000..99c015ffda9 --- /dev/null +++ b/tests/Functional/CrudTest.php @@ -0,0 +1,180 @@ + + * + * 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\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CrudTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private const DUMMY = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'description' => null, + 'dummy' => null, + 'dummyBoolean' => null, + 'dummyDate' => '2015-03-01T10:00:00+00:00', + 'dummyFloat' => null, + 'dummyPrice' => null, + 'relatedDummy' => null, + 'relatedDummies' => [], + 'jsonData' => ['key' => ['value1', 'value2']], + 'arrayData' => [], + 'name_converted' => null, + 'relatedOwnedDummy' => null, + 'relatedOwningDummy' => null, + 'id' => 1, + 'name' => 'My Dummy', + 'alias' => null, + 'foo' => null, + ]; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + private function createDummy(): void + { + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'My Dummy', + 'dummyDate' => '2015-03-01T10:00:00+00:00', + 'jsonData' => ['key' => ['value1', 'value2']], + ], + ]); + } + + public function testCreateDummy(): void + { + $this->createDummy(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/dummies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/dummies/1'); + $this->assertJsonContains(self::DUMMY); + } + + public function testGetItem(): void + { + $this->createDummy(); + + $response = self::createClient()->request('GET', '/dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders())); + $this->assertJsonContains(self::DUMMY); + } + + public function testCreateEmptyBodyReturns400(): void + { + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertJsonContains(['detail' => 'Syntax error']); + } + + public function testNotFoundReturns404(): void + { + $response = self::createClient()->request('GET', '/dummies/42'); + + $this->assertResponseStatusCodeSame(404); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders(false))); + } + + public function testGetCollection(): void + { + $this->createDummy(); + + $response = self::createClient()->request('GET', '/dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/Dummy', $data['@context']); + $this->assertSame('/dummies', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummies/1', $data['hydra:member'][0]['@id']); + $this->assertSame('My Dummy', $data['hydra:member'][0]['name']); + } + + public function testUpdateDummy(): void + { + $this->createDummy(); + + self::createClient()->request('PUT', '/dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/dummies/1', + 'name' => 'A nice dummy', + 'dummyDate' => '2018-12-01 13:12', + 'jsonData' => [['key' => 'value1'], ['key' => 'value2']], + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/dummies/1.jsonld'); + $this->assertJsonContains([ + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'name' => 'A nice dummy', + 'dummyDate' => '2018-12-01T13:12:00+00:00', + 'jsonData' => [['key' => 'value1'], ['key' => 'value2']], + 'id' => 1, + ]); + } + + public function testUpdateEmptyBodyReturns400(): void + { + $this->createDummy(); + + self::createClient()->request('PUT', '/dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertJsonContains(['detail' => 'Syntax error']); + } + + public function testDeleteDummy(): void + { + $this->createDummy(); + + self::createClient()->request('DELETE', '/dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } +} diff --git a/tests/Functional/CrudUriVariablesTest.php b/tests/Functional/CrudUriVariablesTest.php new file mode 100644 index 00000000000..695d3b6938c --- /dev/null +++ b/tests/Functional/CrudUriVariablesTest.php @@ -0,0 +1,206 @@ + + * + * 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\Company; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Employee; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CrudUriVariablesTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Company::class, Employee::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Company::class, Employee::class]); + } + + private function seed(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + $client->request('POST', '/companies', ['headers' => $headers, 'json' => ['name' => 'Foo Company 1']]); + $client->request('POST', '/companies', ['headers' => $headers, 'json' => ['name' => 'Foo Company 2']]); + $client->request('POST', '/employees', ['headers' => $headers, 'json' => ['name' => 'foo', 'company' => '/companies/1']]); + $client->request('POST', '/employees', ['headers' => $headers, 'json' => ['name' => 'foo2', 'company' => '/companies/2']]); + $client->request('POST', '/employees', ['headers' => $headers, 'json' => ['name' => 'foo3', 'company' => '/companies/2']]); + } + + public function testCreateCompany(): void + { + self::createClient()->request('POST', '/companies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Foo Company 1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Location', '/companies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/companies/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/Company', + '@id' => '/companies/1', + '@type' => 'Company', + 'id' => 1, + 'name' => 'Foo Company 1', + 'employees' => [], + ]); + } + + public function testCreateSecondCompany(): void + { + $client = self::createClient(); + $client->request('POST', '/companies', ['headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => 'Foo Company 1']]); + $client->request('POST', '/companies', ['headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => 'Foo Company 2']]); + + $this->assertResponseStatusCodeSame(201); + } + + public function testCreateEmployeeReturnsScopedUri(): void + { + $client = self::createClient(); + $client->request('POST', '/companies', ['headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => 'Foo Company 1']]); + $client->request('POST', '/employees', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo', 'company' => '/companies/1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Location', '/companies/1/employees/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/companies/1/employees/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/Employee', + '@id' => '/companies/1/employees/1', + '@type' => 'Employee', + 'id' => 1, + 'name' => 'foo', + 'company' => '/companies/1', + ]); + } + + public function testGetEmployeesCollectionByCompany(): void + { + $this->seed(); + + self::createClient()->request('GET', '/companies/2/employees', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Employee', + '@id' => '/companies/2/employees', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/companies/2/employees/2', + '@type' => 'Employee', + 'name' => 'foo2', + 'company' => ['@id' => '/companies/2', '@type' => 'Company', 'name' => 'Foo Company 2'], + ], + [ + '@id' => '/companies/2/employees/3', + '@type' => 'Employee', + 'name' => 'foo3', + 'company' => ['@id' => '/companies/2', '@type' => 'Company', 'name' => 'Foo Company 2'], + ], + ], + 'hydra:totalItems' => 2, + ]); + } + + public function testGetCompanyOfEmployee(): void + { + $this->seed(); + + self::createClient()->request('GET', '/employees/1/company', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/Company', + '@id' => '/employees/1/company', + '@type' => 'Company', + 'id' => 1, + 'name' => 'Foo Company 1', + 'employees' => [], + ]); + } + + public function testGetEmployeeWithCompanyUriVariable(): void + { + $this->seed(); + + self::createClient()->request('GET', '/companies/1/employees/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/Employee', + '@id' => '/companies/1/employees/1', + '@type' => 'Employee', + 'id' => 1, + 'name' => 'foo', + 'company' => '/companies/1', + ]); + } + + public function testWrongCompanyContextReturns404(): void + { + $this->seed(); + + self::createClient()->request('GET', '/companies/1/employees/2', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGraphQLCompaniesAndEmployees(): void + { + $this->seed(); + + $response = self::createClient()->request('POST', '/graphql', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['query' => '{ companies { edges { node { name employees { edges { node { name } } } } } } }'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + $data = $response->toArray(); + $companies = $data['data']['companies']['edges']; + $this->assertSame('Foo Company 1', $companies[0]['node']['name']); + $this->assertCount(1, $companies[0]['node']['employees']['edges']); + $this->assertSame('foo', $companies[0]['node']['employees']['edges'][0]['node']['name']); + $this->assertSame('Foo Company 2', $companies[1]['node']['name']); + $this->assertCount(2, $companies[1]['node']['employees']['edges']); + $this->assertSame('foo2', $companies[1]['node']['employees']['edges'][0]['node']['name']); + $this->assertSame('foo3', $companies[1]['node']['employees']['edges'][1]['node']['name']); + } +} diff --git a/tests/Functional/CustomControllerTest.php b/tests/Functional/CustomControllerTest.php new file mode 100644 index 00000000000..450ddbbf27b --- /dev/null +++ b/tests/Functional/CustomControllerTest.php @@ -0,0 +1,217 @@ + + * + * 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\CustomActionDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Payment; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoidPayment; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Ports the @controller-tagged features/main/custom_controller.feature scenarios. + * Controllers return raw entities or JsonResponse and rely on SerializeListener to + * wrap them, so they require USE_SYMFONY_LISTENERS=1 (CI: phpunit_listeners job). + */ +final class CustomControllerTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomActionDummy::class, Payment::class, VoidPayment::class]; + } + + protected function setUp(): void + { + if (!($_SERVER['USE_SYMFONY_LISTENERS'] ?? false)) { + $this->markTestSkipped('Requires USE_SYMFONY_LISTENERS=1.'); + } + + $this->recreateSchema([CustomActionDummy::class, Payment::class, VoidPayment::class]); + } + + public function testCustomDenormalizationRoute(): void + { + self::createClient()->request('POST', '/custom/denormalization', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomActionDummy', + '@id' => '/custom_action_dummies/1', + '@type' => 'CustomActionDummy', + 'id' => 1, + 'foo' => 'custom!', + ]); + } + + public function testCustomNormalizationRoute(): void + { + $this->seedCustomDummy('custom!'); + + $response = self::createClient()->request('GET', '/custom/1/normalization', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertSame(['id' => 1, 'foo' => 'foo'], $response->toArray()); + } + + public function testShortCustomDenormalizationRoute(): void + { + self::createClient()->request('POST', '/short_custom/denormalization', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomActionDummy', + '@id' => '/custom_action_dummies/1', + '@type' => 'CustomActionDummy', + 'id' => 1, + 'foo' => 'short declaration', + ]); + } + + public function testShortCustomNormalizationRoute(): void + { + $this->seedCustomDummy('custom!'); + + $response = self::createClient()->request('GET', '/short_custom/1/normalization', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertSame(['id' => 1, 'foo' => 'short'], $response->toArray()); + } + + public function testCustomCollectionWithoutSpecificRoute(): void + { + $this->seedCustomDummy('first'); + $this->seedCustomDummy('second'); + + $response = self::createClient()->request('GET', '/custom_action_collection_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertCount(2, $response->toArray()['hydra:member']); + } + + public function testCustomItemOperationWithoutSpecificRoute(): void + { + $this->seedCustomDummy('custom!'); + + self::createClient()->request('GET', '/custom_action_collection_dummies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomActionDummy', + '@id' => '/custom_action_collection_dummies/1', + '@type' => 'CustomActionDummy', + 'id' => 1, + 'foo' => 'custom!', + ]); + } + + public function testCreatePayment(): void + { + self::createClient()->request('POST', '/payments', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['amount' => '123.45'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Payment', + '@id' => '/payments/1', + '@type' => 'Payment', + 'id' => 1, + 'amount' => '123.45', + 'voidPayment' => null, + ]); + } + + public function testVoidPayment(): void + { + $this->seedPayment('123.45'); + + self::createClient()->request('POST', '/payments/1/void', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/VoidPayment', + '@id' => '/void_payments/1', + '@type' => 'VoidPayment', + 'id' => 1, + 'payment' => '/payments/1', + ]); + } + + public function testGetVoidPayment(): void + { + $this->seedPayment('123.45'); + self::createClient()->request('POST', '/payments/1/void', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + ]); + + self::createClient()->request('GET', '/void_payments/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/VoidPayment', + '@id' => '/void_payments/1', + '@type' => 'VoidPayment', + 'id' => 1, + 'payment' => '/payments/1', + ]); + } + + private function seedCustomDummy(string $foo): void + { + $manager = $this->getManager(); + $dummy = new CustomActionDummy(); + $dummy->setFoo($foo); + $manager->persist($dummy); + $manager->flush(); + } + + private function seedPayment(string $amount): Payment + { + $manager = $this->getManager(); + $payment = new Payment($amount); + $manager->persist($payment); + $manager->flush(); + + return $payment; + } +} diff --git a/tests/Functional/CustomIdentifierTest.php b/tests/Functional/CustomIdentifierTest.php new file mode 100644 index 00000000000..a68627a7800 --- /dev/null +++ b/tests/Functional/CustomIdentifierTest.php @@ -0,0 +1,172 @@ + + * + * 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\CustomIdentifierDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomMultipleIdentifierDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomIdentifierTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomIdentifierDummy::class, CustomMultipleIdentifierDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([CustomIdentifierDummy::class, CustomMultipleIdentifierDummy::class]); + } + + private function createDummy(): void + { + self::createClient()->request('POST', '/custom_identifier_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy'], + ]); + } + + public function testCreate(): void + { + $this->createDummy(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomIdentifierDummy', + '@id' => '/custom_identifier_dummies/1', + '@type' => 'CustomIdentifierDummy', + 'customId' => 1, + 'name' => 'My Dummy', + ]); + } + + public function testGetItem(): void + { + $this->createDummy(); + + self::createClient()->request('GET', '/custom_identifier_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomIdentifierDummy', + '@id' => '/custom_identifier_dummies/1', + '@type' => 'CustomIdentifierDummy', + 'customId' => 1, + 'name' => 'My Dummy', + ]); + } + + public function testGetCollection(): void + { + $this->createDummy(); + + self::createClient()->request('GET', '/custom_identifier_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomIdentifierDummy', + '@id' => '/custom_identifier_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/custom_identifier_dummies/1', + '@type' => 'CustomIdentifierDummy', + 'customId' => 1, + 'name' => 'My Dummy', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testUpdate(): void + { + $this->createDummy(); + + self::createClient()->request('PUT', '/custom_identifier_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomIdentifierDummy', + '@id' => '/custom_identifier_dummies/1', + '@type' => 'CustomIdentifierDummy', + 'customId' => 1, + 'name' => 'My Dummy modified', + ]); + } + + public function testApiDocReportsCustomIdentifierClass(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $classes = array_filter($data['hydra:supportedClass'], static fn ($c) => 'CustomIdentifierDummy' === $c['hydra:title']); + $this->assertCount(1, $classes, 'CustomIdentifierDummy is missing from /docs.jsonld'); + $class = reset($classes); + $properties = array_column($class['hydra:supportedProperty'] ?? [], 'hydra:title'); + $this->assertContains('name', $properties); + } + + public function testDelete(): void + { + $this->createDummy(); + + self::createClient()->request('DELETE', '/custom_identifier_dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testGetCustomMultipleIdentifierDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('CustomMultipleIdentifierDummy fixture is ORM-only.'); + } + + $manager = $this->getManager(); + $dummy = new CustomMultipleIdentifierDummy(); + $dummy->setName('Orwell'); + $dummy->setFirstId(1); + $dummy->setSecondId(2); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/custom_multiple_identifier_dummies/1/2'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomMultipleIdentifierDummy', + '@id' => '/custom_multiple_identifier_dummies/1/2', + '@type' => 'CustomMultipleIdentifierDummy', + 'firstId' => 1, + 'secondId' => 2, + 'name' => 'Orwell', + ]); + } +} diff --git a/tests/Functional/CustomIdentifierWithSubresourceTest.php b/tests/Functional/CustomIdentifierWithSubresourceTest.php new file mode 100644 index 00000000000..fd91785325b --- /dev/null +++ b/tests/Functional/CustomIdentifierWithSubresourceTest.php @@ -0,0 +1,137 @@ + + * + * 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\SlugChildDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SlugParentDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomIdentifierWithSubresourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [SlugParentDummy::class, SlugChildDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema($this->getResources()); + } + + private function seed(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + $client->request('POST', '/slug_parent_dummies', ['headers' => $headers, 'json' => ['slug' => 'parent-dummy']]); + $client->request('POST', '/slug_child_dummies', [ + 'headers' => $headers, + 'json' => ['slug' => 'child-dummy', 'parentDummy' => '/slug_parent_dummies/parent-dummy'], + ]); + } + + public function testCreateParentWithSlug(): void + { + self::createClient()->request('POST', '/slug_parent_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['slug' => 'parent-dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/SlugParentDummy', + '@id' => '/slug_parent_dummies/parent-dummy', + '@type' => 'SlugParentDummy', + 'id' => 1, + 'slug' => 'parent-dummy', + 'childDummies' => [], + ]); + } + + public function testCreateChildReferencingParentBySlug(): void + { + self::createClient()->request('POST', '/slug_parent_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['slug' => 'parent-dummy'], + ]); + self::createClient()->request('POST', '/slug_child_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['slug' => 'child-dummy', 'parentDummy' => '/slug_parent_dummies/parent-dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/SlugChildDummy', + '@id' => '/slug_child_dummies/child-dummy', + '@type' => 'SlugChildDummy', + 'id' => 1, + 'slug' => 'child-dummy', + 'parentDummy' => '/slug_parent_dummies/parent-dummy', + ]); + } + + public function testGetChildDummiesOfParentBySlug(): void + { + $this->seed(); + + self::createClient()->request('GET', '/slug_parent_dummies/parent-dummy/child_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/SlugChildDummy', + '@id' => '/slug_parent_dummies/parent-dummy/child_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/slug_child_dummies/child-dummy', + '@type' => 'SlugChildDummy', + 'id' => 1, + 'slug' => 'child-dummy', + 'parentDummy' => '/slug_parent_dummies/parent-dummy', + ], + ], + 'hydra:totalItems' => 1, + ]); + } + + public function testGetParentOfChildBySlug(): void + { + $this->seed(); + + self::createClient()->request('GET', '/slug_child_dummies/child-dummy/parent_dummy'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/SlugParentDummy', + '@id' => '/slug_child_dummies/child-dummy/parent_dummy', + '@type' => 'SlugParentDummy', + 'id' => 1, + 'slug' => 'parent-dummy', + 'childDummies' => ['/slug_child_dummies/child-dummy'], + ]); + } +} diff --git a/tests/Functional/CustomNormalizedTest.php b/tests/Functional/CustomNormalizedTest.php new file mode 100644 index 00000000000..7fca11e774a --- /dev/null +++ b/tests/Functional/CustomNormalizedTest.php @@ -0,0 +1,204 @@ + + * + * 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\CustomNormalizedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedNormalizedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomNormalizedTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomNormalizedDummy::class, RelatedNormalizedDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([CustomNormalizedDummy::class, RelatedNormalizedDummy::class]); + } + + private function createCustom(): void + { + self::createClient()->request('POST', '/custom_normalized_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy', 'alias' => 'My alias'], + ]); + } + + public function testCreateCustomNormalized(): void + { + $this->createCustom(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/custom_normalized_dummies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/custom_normalized_dummies/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy', + 'alias' => 'My alias', + ]); + } + + public function testCreateRelatedNormalizedReturnsJson(): void + { + self::createClient()->request('POST', '/related_normalized_dummies', [ + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + 'json' => ['name' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/related_normalized_dummies/1.json'); + $this->assertResponseHeaderSame('Location', '/related_normalized_dummies/1'); + $this->assertJsonEquals(['id' => 1, 'name' => 'My Dummy', 'customNormalizedDummy' => []]); + } + + public function testPutRelatedNormalizedReplacesEmbeddedDummies(): void + { + $this->createCustom(); + self::createClient()->request('POST', '/related_normalized_dummies', [ + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + 'json' => ['name' => 'My Dummy'], + ]); + + self::createClient()->request('PUT', '/related_normalized_dummies/1', [ + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + 'json' => [ + 'name' => 'My Dummy', + 'customNormalizedDummy' => [[ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy', + ]], + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/related_normalized_dummies/1.json'); + $this->assertJsonEquals([ + 'id' => 1, + 'name' => 'My Dummy', + 'customNormalizedDummy' => [['id' => 1, 'name' => 'My Dummy', 'alias' => 'My alias']], + ]); + } + + public function testGetCustomNormalizedItem(): void + { + $this->createCustom(); + + self::createClient()->request('GET', '/custom_normalized_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy', + 'alias' => 'My alias', + ]); + } + + public function testGetCustomNormalizedCollection(): void + { + $this->createCustom(); + + self::createClient()->request('GET', '/custom_normalized_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy', + 'alias' => 'My alias', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testPutCustomNormalizedRetainsExistingAlias(): void + { + $this->createCustom(); + + self::createClient()->request('PUT', '/custom_normalized_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/custom_normalized_dummies/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy modified', + 'alias' => 'My alias', + ]); + } + + public function testPatchCustomNormalized(): void + { + $this->createCustom(); + + self::createClient()->request('PATCH', '/custom_normalized_dummies/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'My Dummy modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/custom_normalized_dummies/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy modified', + 'alias' => 'My alias', + ]); + } + + public function testDeleteCustomNormalized(): void + { + $this->createCustom(); + + self::createClient()->request('DELETE', '/custom_normalized_dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } +} diff --git a/tests/Functional/CustomPutTest.php b/tests/Functional/CustomPutTest.php new file mode 100644 index 00000000000..d901a8034bd --- /dev/null +++ b/tests/Functional/CustomPutTest.php @@ -0,0 +1,59 @@ + + * + * 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\CustomPut; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomPutTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomPut::class]; + } + + public function testPutWithoutReadOrAllowCreateReturns200(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([CustomPut::class]); + + self::createClient()->request('PUT', '/custom_puts/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'a', 'bar' => 'b'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomPut', + '@id' => '/custom_puts/1', + '@type' => 'CustomPut', + 'id' => 1, + 'foo' => 'a', + 'bar' => 'b', + ]); + } +} diff --git a/tests/Functional/CustomWritableIdentifierTest.php b/tests/Functional/CustomWritableIdentifierTest.php new file mode 100644 index 00000000000..48affd6dd5a --- /dev/null +++ b/tests/Functional/CustomWritableIdentifierTest.php @@ -0,0 +1,153 @@ + + * + * 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\CustomWritableIdentifierDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomWritableIdentifierTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomWritableIdentifierDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([CustomWritableIdentifierDummy::class]); + } + + private function createWithSlug(string $name, string $slug): void + { + self::createClient()->request('POST', '/custom_writable_identifier_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => $name, 'slug' => $slug], + ]); + } + + public function testCreateWithWritableSlug(): void + { + $this->createWithSlug('My Dummy', 'my_slug'); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/custom_writable_identifier_dummies/my_slug.jsonld'); + $this->assertResponseHeaderSame('Location', '/custom_writable_identifier_dummies/my_slug'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomWritableIdentifierDummy', + '@id' => '/custom_writable_identifier_dummies/my_slug', + '@type' => 'CustomWritableIdentifierDummy', + 'slug' => 'my_slug', + 'name' => 'My Dummy', + ]); + } + + public function testGetItemBySlug(): void + { + $this->createWithSlug('My Dummy', 'my_slug'); + + self::createClient()->request('GET', '/custom_writable_identifier_dummies/my_slug'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomWritableIdentifierDummy', + '@id' => '/custom_writable_identifier_dummies/my_slug', + '@type' => 'CustomWritableIdentifierDummy', + 'slug' => 'my_slug', + 'name' => 'My Dummy', + ]); + } + + public function testGetCollection(): void + { + $this->createWithSlug('My Dummy', 'my_slug'); + + self::createClient()->request('GET', '/custom_writable_identifier_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomWritableIdentifierDummy', + '@id' => '/custom_writable_identifier_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/custom_writable_identifier_dummies/my_slug', + '@type' => 'CustomWritableIdentifierDummy', + 'slug' => 'my_slug', + 'name' => 'My Dummy', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testPutChangesIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->createWithSlug('My Dummy', 'my_slug'); + + self::createClient()->request('PUT', '/custom_writable_identifier_dummies/my_slug', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy modified', 'slug' => 'slug_modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/custom_writable_identifier_dummies/slug_modified.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomWritableIdentifierDummy', + '@id' => '/custom_writable_identifier_dummies/slug_modified', + '@type' => 'CustomWritableIdentifierDummy', + 'slug' => 'slug_modified', + 'name' => 'My Dummy modified', + ]); + } + + public function testApiDocReportsClass(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $classes = array_filter($data['hydra:supportedClass'], static fn ($c) => 'CustomWritableIdentifierDummy' === $c['hydra:title']); + $this->assertCount(1, $classes); + $class = reset($classes); + $properties = array_column($class['hydra:supportedProperty'] ?? [], 'hydra:title'); + $this->assertContains('name', $properties); + $this->assertContains('slug', $properties); + } + + public function testDelete(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->createWithSlug('My Dummy', 'my_slug'); + + self::createClient()->request('DELETE', '/custom_writable_identifier_dummies/my_slug'); + + $this->assertResponseStatusCodeSame(204); + } +} diff --git a/tests/Functional/DefaultOrderTest.php b/tests/Functional/DefaultOrderTest.php new file mode 100644 index 00000000000..2d48386c8d1 --- /dev/null +++ b/tests/Functional/DefaultOrderTest.php @@ -0,0 +1,143 @@ + + * + * 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\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class DefaultOrderTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Foo::class, FooDummy::class, Dummy::class, SoMany::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + } + + private function seedFoos(): void + { + $manager = $this->getManager(); + $names = ['Hawsepipe', 'Sthenelus', 'Ephesian', 'Separativeness', 'Balbo']; + $bars = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + for ($i = 0; $i < 5; ++$i) { + $foo = new Foo(); + $foo->setName($names[$i]); + $foo->setBar($bars[$i]); + $manager->persist($foo); + } + $manager->flush(); + $manager->clear(); + } + + public function testDefaultOrderOnFooCollection(): void + { + $this->seedFoos(); + + self::createClient()->request('GET', '/foos?itemsPerPage=10'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Foo', + '@id' => '/foos', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + ['@id' => '/foos/5', '@type' => 'Foo', 'id' => 5, 'name' => 'Balbo', 'bar' => 'Amet'], + ['@id' => '/foos/3', '@type' => 'Foo', 'id' => 3, 'name' => 'Ephesian', 'bar' => 'Dolor'], + ['@id' => '/foos/2', '@type' => 'Foo', 'id' => 2, 'name' => 'Sthenelus', 'bar' => 'Ipsum'], + ['@id' => '/foos/1', '@type' => 'Foo', 'id' => 1, 'name' => 'Hawsepipe', 'bar' => 'Lorem'], + ['@id' => '/foos/4', '@type' => 'Foo', 'id' => 4, 'name' => 'Separativeness', 'bar' => 'Sit'], + ], + 'hydra:totalItems' => 5, + 'hydra:view' => [ + '@id' => '/foos?itemsPerPage=10', + '@type' => 'hydra:PartialCollectionView', + ], + ]); + } + + public function testDefaultOrderByAssociationOnFooDummy(): void + { + $manager = $this->getManager(); + $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; + $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + for ($i = 0; $i < 5; ++$i) { + $dummy = new Dummy(); + $dummy->setName($dummies[$i]); + $foo = new FooDummy(); + $foo->setName($names[$i]); + $foo->setDummy($dummy); + for ($j = 0; $j < 3; ++$j) { + $soMany = new SoMany(); + $soMany->content = "So many $j"; + $soMany->fooDummy = $foo; + $foo->soManies->add($soMany); + } + $manager->persist($foo); + } + $manager->flush(); + $manager->clear(); + + $response = self::createClient()->request('GET', '/foo_dummies?itemsPerPage=10'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $names = array_column($data['hydra:member'], 'name'); + $this->assertSame(['Balbo', 'Sthenelus', 'Ephesian', 'Hawsepipe', 'Separativeness'], $names); + $this->assertSame(5, $data['hydra:totalItems']); + } + + public function testCustomCollectionOrderAsc(): void + { + $this->seedFoos(); + + $response = self::createClient()->request('GET', '/custom_collection_asc_foos?itemsPerPage=10'); + + $this->assertResponseStatusCodeSame(200); + $names = array_column($response->toArray()['hydra:member'], 'name'); + $this->assertSame(['Balbo', 'Ephesian', 'Hawsepipe', 'Separativeness', 'Sthenelus'], $names); + } + + public function testCustomCollectionOrderDesc(): void + { + $this->seedFoos(); + + $response = self::createClient()->request('GET', '/custom_collection_desc_foos?itemsPerPage=10'); + + $this->assertResponseStatusCodeSame(200); + $names = array_column($response->toArray()['hydra:member'], 'name'); + $this->assertSame(['Sthenelus', 'Separativeness', 'Hawsepipe', 'Ephesian', 'Balbo'], $names); + } +} diff --git a/tests/Functional/ExceptionToStatusTest.php b/tests/Functional/ExceptionToStatusTest.php new file mode 100644 index 00000000000..66563a6d022 --- /dev/null +++ b/tests/Functional/ExceptionToStatusTest.php @@ -0,0 +1,97 @@ + + * + * 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\ApiResource\ErrorWithOverridenStatus; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5924\TooManyRequests; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyExceptionToStatus; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ExceptionToStatusTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyExceptionToStatus::class, ErrorWithOverridenStatus::class, TooManyRequests::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([DummyExceptionToStatus::class]); + } + + public function testOperationExceptionToStatusMaps404(): void + { + self::createClient()->request('GET', '/dummy_exception_to_statuses/123', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testResourceExceptionToStatusMaps400(): void + { + self::createClient()->request('PUT', '/dummy_exception_to_statuses/123', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'black'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testFilterValidationExceptionMaps400(): void + { + self::createClient()->request('GET', '/dummy_exception_to_statuses', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testOverrideValidationExceptionStatusOnDelete(): void + { + self::createClient()->request('DELETE', '/error_with_overriden_status/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(403); + $this->assertJsonContains(['status' => 403]); + } + + public function testHttpExceptionHeadersAreRetained(): void + { + self::createClient()->request('GET', '/issue5924', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(429); + $this->assertResponseHeaderSame('retry-after', '32'); + } +} diff --git a/tests/Functional/ExposedStateTest.php b/tests/Functional/ExposedStateTest.php new file mode 100644 index 00000000000..1e2922bbaa5 --- /dev/null +++ b/tests/Functional/ExposedStateTest.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; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TruncatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ExposedStateTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [TruncatedDummy::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + if (!$this->isPostgres()) { + $this->markTestSkipped('Decimal truncation is enforced by Postgres only.'); + } + + $this->recreateSchema($this->getResources()); + } + + public function testCreateReturnsTruncatedValue(): void + { + self::createClient()->request('POST', '/truncated_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['value' => '20.3325'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/TruncatedDummy', + '@id' => '/truncated_dummies/1', + '@type' => 'TruncatedDummy', + 'value' => '20.3', + 'id' => 1, + ]); + } + + public function testUpdateReturnsTruncatedValue(): void + { + $client = self::createClient(); + $client->request('POST', '/truncated_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['value' => '20.3325'], + ]); + + $client->request('PUT', '/truncated_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['value' => '42.42'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/TruncatedDummy', + '@id' => '/truncated_dummies/1', + '@type' => 'TruncatedDummy', + 'value' => '42.4', + 'id' => 1, + ]); + } +} diff --git a/tests/Functional/HeadersAdditionTest.php b/tests/Functional/HeadersAdditionTest.php new file mode 100644 index 00000000000..15b6acbcad3 --- /dev/null +++ b/tests/Functional/HeadersAdditionTest.php @@ -0,0 +1,55 @@ + + * + * 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\ApiResource\Headers; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HeadersAdditionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyCar::class, Headers::class]; + } + + public function testSunsetHeaderOnResourceCollection(): void + { + $this->recreateSchema([DummyCar::class]); + + self::createClient()->request('GET', '/dummy_cars'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Sunset', 'Sat, 01 Jan 2050 00:00:00 +0000'); + } + + public function testDeclareHeadersFromResource(): void + { + self::createClient()->request('GET', '/redirect_to_foobar'); + + $this->assertResponseStatusCodeSame(301); + $this->assertResponseHeaderSame('Location', '/foobar'); + $this->assertResponseHeaderSame('Hello', 'World'); + } +} diff --git a/tests/Functional/Json/OutputAndEntityClassTest.php b/tests/Functional/Json/OutputAndEntityClassTest.php new file mode 100644 index 00000000000..766de7c2fa6 --- /dev/null +++ b/tests/Functional/Json/OutputAndEntityClassTest.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\Functional\Json; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6358\OutputAndEntityClass; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OutputAndEntityClassTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [OutputAndEntityClass::class]; + } + + public function testCollectionUsesEntityClassFromStateOptionsForType(): void + { + if ('mongodb' === static::getContainer()->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/output_and_entity_classes', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + 'hydra:member' => [ + ['@type' => 'OutputAndEntityClassEntity'], + ], + ]); + } +} diff --git a/tests/Functional/JsonLd/SerializableItemDataProviderTest.php b/tests/Functional/JsonLd/SerializableItemDataProviderTest.php new file mode 100644 index 00000000000..d20032d311c --- /dev/null +++ b/tests/Functional/JsonLd/SerializableItemDataProviderTest.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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\SerializableResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SerializableItemDataProviderTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [SerializableResource::class]; + } + + public function testGetSerializableResource(): void + { + self::createClient()->request('GET', '/serializable_resources/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/SerializableResource', + '@id' => '/serializable_resources/1', + '@type' => 'SerializableResource', + 'id' => 1, + 'foo' => 'Lorem', + 'bar' => 'Ipsum', + ]); + } +} diff --git a/tests/Functional/NotExposedTest.php b/tests/Functional/NotExposedTest.php new file mode 100644 index 00000000000..58ab2f5d9ff --- /dev/null +++ b/tests/Functional/NotExposedTest.php @@ -0,0 +1,165 @@ + + * + * 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\Model\Chair; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\Fork; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\Spoon; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\Table; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class NotExposedTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chair::class, Table::class, Fork::class, Spoon::class]; + } + + public function testChairsCollectionIsExposedWithGenIdIris(): void + { + $response = self::createClient()->request('GET', '/chairs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/Chair', $data['@context']); + $this->assertSame('/chairs', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertCount(2, $data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/.well-known/genid/.+$#', $member['@id']); + $this->assertSame('Chair', $member['@type']); + } + } + + public function testTablesCollectionExposesItemIris(): void + { + $response = self::createClient()->request('GET', '/tables', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('/contexts/Table', $data['@context']); + $this->assertSame(2, $data['hydra:totalItems']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/tables/.+$#', $member['@id']); + $this->assertSame('Table', $member['@type']); + } + } + + public static function forkUris(): iterable + { + yield ['/forks']; + yield ['/fourchettes']; + } + + #[DataProvider('forkUris')] + public function testForkMultipleCollectionsExposed(string $uri): void + { + $response = self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('/contexts/Fork', $data['@context']); + $this->assertSame(2, $data['hydra:totalItems']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/forks/.+$#', $member['@id']); + $this->assertSame('Fork', $member['@type']); + } + } + + public static function spoonUris(): iterable + { + yield ['/spoons']; + yield ['/cuillers']; + } + + #[DataProvider('spoonUris')] + public function testSpoonCollectionExposesCuillersAsItemIris(string $uri): void + { + $response = self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('/contexts/Spoon', $data['@context']); + $this->assertSame(2, $data['hydra:totalItems']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/cuillers/.+$#', $member['@id']); + $this->assertSame('Spoon', $member['@type']); + } + } + + public static function notExposedItemUris(): iterable + { + yield ['/tables/12345']; + yield ['/forks/12345']; + } + + #[DataProvider('notExposedItemUris')] + public function testNotExposedItemReturns404(string $uri): void + { + self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertJsonContains(['detail' => 'This route does not aim to be called.']); + } + + public function testGenidNotExposedReturns404WithExplanation(): void + { + self::createClient()->request('GET', '/.well-known/genid/12345', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertJsonContains([ + 'detail' => 'This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation.', + ]); + } + + public function testSpoonItemViaCuillersIsExposed(): void + { + self::createClient()->request('GET', '/cuillers/12345', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Spoon', + '@id' => '/cuillers/12345', + '@type' => 'Spoon', + 'id' => '12345', + 'owner' => 'Vincent', + ]); + } +} diff --git a/tests/Functional/OperationResourceTest.php b/tests/Functional/OperationResourceTest.php new file mode 100644 index 00000000000..826c4042bc6 --- /dev/null +++ b/tests/Functional/OperationResourceTest.php @@ -0,0 +1,103 @@ + + * + * 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\OperationResource; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OperationResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [OperationResource::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + } + + private function seedOne(): void + { + self::createClient()->request('POST', '/operation_resources', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['identifier' => 1, 'dummy' => null, 'name' => 'string'], + ]); + } + + public function testCreateOperationResource(): void + { + self::createClient()->request('POST', '/operation_resources', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['identifier' => 1, 'dummy' => null, 'name' => 'string'], + ]); + + $this->assertResponseStatusCodeSame(201); + } + + public function testPatchOperationResource(): void + { + $this->seedOne(); + + self::createClient()->request('PATCH', '/operation_resources/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'Patched'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/OperationResource', + '@id' => '/operation_resources/1', + '@type' => 'OperationResource', + 'identifier' => 1, + 'name' => 'Patched', + ]); + } + + public function testPutOperationResource(): void + { + $this->seedOne(); + + self::createClient()->request('PUT', '/operation_resources/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/operation_resources/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/OperationResource', + '@id' => '/operation_resources/1', + '@type' => 'OperationResource', + 'identifier' => 1, + 'name' => 'Modified', + ]); + } +} diff --git a/tests/Functional/OperationTest.php b/tests/Functional/OperationTest.php new file mode 100644 index 00000000000..a8e175d561d --- /dev/null +++ b/tests/Functional/OperationTest.php @@ -0,0 +1,155 @@ + + * + * 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\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DisableItemOperation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ReadableOnlyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OperationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ReadableOnlyProperty::class, RelationEmbedder::class, EmbeddedDummy::class, DisableItemOperation::class, Book::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([ReadableOnlyProperty::class, RelationEmbedder::class, EmbeddedDummy::class, DisableItemOperation::class, Book::class]); + } + + public function testReadOnlyPropertyIgnoresInput(): void + { + self::createClient()->request('POST', '/readable_only_properties', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/ReadableOnlyProperty', + '@id' => '/readable_only_properties/1', + '@type' => 'ReadableOnlyProperty', + 'id' => 1, + 'name' => 'Read only', + ]); + } + + public function testCustomOperationOnRelationEmbedder(): void + { + $response = self::createClient()->request('GET', '/relation_embedders/42/custom'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertSame('"This is a custom action for 42."', $response->getContent()); + } + + public function testEmbeddedDummyWithGroups(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('EmbeddedDummy fixture uses ORM Embeddable.'); + } + + $manager = $this->getManager(); + $dummy = new EmbeddedDummy(); + $dummy->setName('Dummy #1'); + $embeddable = new EmbeddableDummy(); + $embeddable->setDummyName('Dummy #1'); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/embedded_dummies_groups/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/EmbeddedDummy', + '@id' => '/embedded_dummies_groups/1', + '@type' => 'EmbeddedDummy', + 'name' => 'Dummy #1', + 'embeddedDummy' => [ + '@type' => 'EmbeddableDummy', + 'dummyName' => 'Dummy #1', + ], + ]); + } + + public function testCollectionOnResourceWithDisabledItemOperation(): void + { + self::createClient()->request('GET', '/disable_item_operations'); + + $this->assertResponseStatusCodeSame(200); + } + + public function testDisabledItemOperationReturns404(): void + { + self::createClient()->request('GET', '/disable_item_operations/1'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetBookByCustomUriTemplate(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Book fixture is ORM-only.'); + } + + $manager = $this->getManager(); + $book = new Book(); + $book->name = '1984'; + $book->isbn = '9780451524935'; + $manager->persist($book); + $manager->flush(); + + self::createClient()->request('GET', '/books/by_isbn/9780451524935'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Book', + '@id' => '/books/by_isbn/9780451524935', + '@type' => 'Book', + 'name' => '1984', + 'isbn' => '9780451524935', + 'id' => 1, + ]); + } + + public function testNonApiPlatformRouteIsReachable(): void + { + self::createClient()->request('GET', '/common/custom/object'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + 'id' => 1, + 'text' => 'Lorem ipsum dolor sit amet', + ]); + } +} diff --git a/tests/Functional/OverriddenOperationTest.php b/tests/Functional/OverriddenOperationTest.php new file mode 100644 index 00000000000..b75adf724ec --- /dev/null +++ b/tests/Functional/OverriddenOperationTest.php @@ -0,0 +1,206 @@ + + * + * 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\OverriddenOperationDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RPC; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OverriddenOperationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [OverriddenOperationDummy::class, RPC::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([OverriddenOperationDummy::class]); + } + + private function createDummy(): void + { + self::createClient()->request('POST', '/overridden_operation_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'My Overridden Operation Dummy', + 'description' => 'Gerard', + 'alias' => 'notWritable', + ], + ]); + } + + public function testCreateRespectsNotWritable(): void + { + $this->createDummy(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'name' => 'My Overridden Operation Dummy', + 'alias' => null, + 'description' => 'Gerard', + ]); + } + + public function testGetItem(): void + { + $this->createDummy(); + + self::createClient()->request('GET', '/overridden_operation_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'name' => 'My Overridden Operation Dummy', + 'alias' => null, + 'description' => 'Gerard', + ]); + } + + public function testGetItemInXml(): void + { + $this->createDummy(); + + $response = self::createClient()->request('GET', '/overridden_operation_dummies/1', [ + 'headers' => ['Accept' => 'application/xml'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertSame( + ''."\n".'My Overridden Operation DummyGerard'."\n", + $response->getContent() + ); + } + + public function testNotFound(): void + { + self::createClient()->request('GET', '/overridden_operation_dummies/42'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetCollection(): void + { + $this->createDummy(); + + self::createClient()->request('GET', '/overridden_operation_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'name' => 'My Overridden Operation Dummy', + 'alias' => null, + 'description' => 'Gerard', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testPutHidesName(): void + { + $this->createDummy(); + + self::createClient()->request('PUT', '/overridden_operation_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/overridden_operation_dummies/1', + 'name' => 'A nice dummy', + 'alias' => 'Dummy', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'alias' => 'Dummy', + 'description' => 'Gerard', + ]); + } + + public function testGetItemAfterPutShowsName(): void + { + $this->createDummy(); + self::createClient()->request('PUT', '/overridden_operation_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['@id' => '/overridden_operation_dummies/1', 'name' => 'A nice dummy', 'alias' => 'Dummy'], + ]); + + self::createClient()->request('GET', '/overridden_operation_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'name' => 'My Overridden Operation Dummy', + 'alias' => 'Dummy', + 'description' => 'Gerard', + ]); + } + + public function testDelete(): void + { + $this->createDummy(); + + self::createClient()->request('DELETE', '/overridden_operation_dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testRpcMessengerOperationReturns202(): void + { + self::createClient()->request('POST', '/rpc', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['value' => 'Hello world'], + ]); + + $this->assertResponseStatusCodeSame(202); + } + + public function testRpcOperationWithOutputDtoReturns200(): void + { + self::createClient()->request('POST', '/rpc_output', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['value' => 'Hello world'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains(['success' => 'YES', '@type' => 'RPCOutput']); + } +} diff --git a/tests/Functional/PatchTest.php b/tests/Functional/PatchTest.php new file mode 100644 index 00000000000..c78fc9720aa --- /dev/null +++ b/tests/Functional/PatchTest.php @@ -0,0 +1,165 @@ + + * + * 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\ApiResource\Issue5736\Alpha; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5736\Beta; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6355\OrderProductCount; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PatchDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PatchDummyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PatchTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [PatchDummy::class, PatchDummyRelation::class, RelatedDummy::class, Beta::class, Alpha::class, OrderProductCount::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([PatchDummy::class, PatchDummyRelation::class, RelatedDummy::class]); + } + + public function testAcceptPatchHeader(): void + { + $client = self::createClient(); + $client->request('POST', '/patch_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello'], + ]); + $response = $client->request('GET', '/patch_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseHeaderSame('Accept-Patch', 'application/merge-patch+json, application/vnd.api+json'); + } + + public function testPatchItem(): void + { + $client = self::createClient(); + $client->request('POST', '/patch_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello'], + ]); + + $client->request('PATCH', '/patch_dummies/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'Patched'], + ]); + + $this->assertJsonContains(['name' => 'Patched']); + } + + public function testPatchRemovesPropertyWithNull(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('PatchDummy fixture is ORM-only.'); + } + + $client = self::createClient(); + $client->request('POST', '/patch_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello'], + ]); + + $response = $client->request('PATCH', '/patch_dummies/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => null], + ]); + + $data = $response->toArray(); + $this->assertArrayNotHasKey('name', $data); + } + + public function testPatchRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('PatchDummyRelation/RelatedDummy fixtures are ORM-only.'); + } + + $manager = $this->getManager(); + $related = new RelatedDummy(); + $manager->persist($related); + $manager->flush(); + $dummy = new PatchDummyRelation(); + $dummy->setRelated($related); + $manager->persist($dummy); + $manager->flush(); + $manager->clear(); + + self::createClient()->request('PATCH', '/patch_dummy_relations/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['related' => ['symfony' => 'A new name']], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/PatchDummyRelation', + '@id' => '/patch_dummy_relations/1', + '@type' => 'PatchDummyRelation', + 'related' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'id' => 1, + 'symfony' => 'A new name', + ], + ]); + } + + public function testPatchRelationWithNonIdUriVariable(): void + { + self::createClient()->request('PATCH', '/betas/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['alpha' => '/alphas/2'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Beta', + '@id' => '/betas/1', + '@type' => 'Beta', + 'betaId' => 1, + 'alpha' => '/alphas/2', + ]); + } + + public function testPatchNonReadableResource(): void + { + if (!($_SERVER['USE_SYMFONY_LISTENERS'] ?? false)) { + $this->markTestSkipped('Requires USE_SYMFONY_LISTENERS=1.'); + } + + $response = self::createClient()->request('PATCH', '/order_products/1/count', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['id' => 1, 'count' => 10], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertSame(1, $response->toArray()['id']); + } +} diff --git a/tests/Functional/ProviderProcessorEntityTest.php b/tests/Functional/ProviderProcessorEntityTest.php new file mode 100644 index 00000000000..ec0697814f5 --- /dev/null +++ b/tests/Functional/ProviderProcessorEntityTest.php @@ -0,0 +1,132 @@ + + * + * 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\ProcessorEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ProviderEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ProviderProcessorEntityTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ProcessorEntity::class, ProviderEntity::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Processor/Provider entity fixtures are ORM-only.'); + } + + $this->recreateSchema([ProcessorEntity::class, ProviderEntity::class]); + } + + public function testCreateProcessorEntity(): void + { + self::createClient()->request('POST', '/processor_entities', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'bar'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/processor_entities/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/processor_entities/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/ProcessorEntity', + '@id' => '/processor_entities/1', + '@type' => 'ProcessorEntity', + 'id' => 1, + 'foo' => 'bar', + ]); + } + + public function testCreateProviderEntity(): void + { + self::createClient()->request('POST', '/provider_entities', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'bar'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/provider_entities/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/provider_entities/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/ProviderEntity', + '@id' => '/provider_entities/1', + '@type' => 'ProviderEntity', + 'id' => 1, + 'foo' => 'bar', + ]); + } + + public function testGetProviderEntityCollection(): void + { + self::createClient()->request('POST', '/provider_entities', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'bar'], + ]); + + $response = self::createClient()->request('GET', '/provider_entities'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders())); + $this->assertJsonEquals([ + '@context' => '/contexts/ProviderEntity', + '@id' => '/provider_entities', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/provider_entities/1', + '@type' => 'ProviderEntity', + 'id' => 1, + 'foo' => 'bar', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testGetProviderEntityItem(): void + { + self::createClient()->request('POST', '/provider_entities', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'bar'], + ]); + + $response = self::createClient()->request('GET', '/provider_entities/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders())); + $this->assertJsonEquals([ + '@context' => '/contexts/ProviderEntity', + '@id' => '/provider_entities/1', + '@type' => 'ProviderEntity', + 'id' => 1, + 'foo' => 'bar', + ]); + } +} diff --git a/tests/Functional/PutCollectionTest.php b/tests/Functional/PutCollectionTest.php new file mode 100644 index 00000000000..f5f76ddd419 --- /dev/null +++ b/tests/Functional/PutCollectionTest.php @@ -0,0 +1,71 @@ + + * + * 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\Issue5587\Business; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5587\Employee; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PutCollectionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Business::class, Employee::class]; + } + + public function testPutReplacesEmbeddedCollection(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + + $client->request('POST', '/issue5584_employees', ['headers' => $headers, 'json' => ['name' => 'One']]); + $client->request('POST', '/issue5584_employees', ['headers' => $headers, 'json' => ['name' => 'Two']]); + $client->request('POST', '/issue5584_businesses', ['headers' => $headers, 'json' => ['name' => 'Business']]); + + $client->request('PUT', '/issue5584_businesses/1', [ + 'headers' => $headers, + 'json' => [ + 'name' => 'Business', + 'businessEmployees' => [ + ['@id' => '/issue5584_employees/1', 'id' => 1], + ['@id' => '/issue5584_employees/2', 'id' => 2], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'businessEmployees' => [ + ['name' => 'One'], + ['name' => 'Two'], + ], + ]); + } +} diff --git a/tests/Functional/RelationTest.php b/tests/Functional/RelationTest.php new file mode 100644 index 00000000000..8fc88a1648b --- /dev/null +++ b/tests/Functional/RelationTest.php @@ -0,0 +1,480 @@ + + * + * 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\Address; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Customer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Order; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PersonToPet; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Pet; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class RelationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + ThirdLevel::class, + DummyFriend::class, + RelatedDummy::class, + RelatedToDummyFriend::class, + RelationEmbedder::class, + Dummy::class, + Order::class, + Customer::class, + Address::class, + Person::class, + Pet::class, + ]; + } + + private function seedBasics(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + $client->request('POST', '/third_levels', ['headers' => $headers, 'json' => ['level' => 3]]); + $client->request('POST', '/dummy_friends', ['headers' => $headers, 'json' => ['name' => 'Zoidberg']]); + $client->request('POST', '/related_dummies', ['headers' => $headers, 'json' => ['thirdLevel' => '/third_levels/1']]); + } + + public function testCreateThirdLevel(): void + { + $this->recreateSchema([ThirdLevel::class]); + + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['level' => 3], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/ThirdLevel', + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'fourthLevel' => null, + 'badFourthLevel' => null, + 'id' => 1, + 'level' => 3, + 'test' => true, + 'relatedDummies' => [], + ]); + } + + public function testCreateDummyFriend(): void + { + $this->recreateSchema([DummyFriend::class]); + + self::createClient()->request('POST', '/dummy_friends', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Zoidberg'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/DummyFriend', + '@id' => '/dummy_friends/1', + '@type' => 'DummyFriend', + 'id' => 1, + 'name' => 'Zoidberg', + ]); + } + + public function testCreateRelatedDummyWithThirdLevel(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class]); + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['level' => 3], + ]); + + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['thirdLevel' => '/third_levels/1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/RelatedDummy', + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'id' => 1, + 'symfony' => 'symfony', + 'thirdLevel' => [ + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'fourthLevel' => null, + ], + ]); + } + + public function testCreateFriendRelationship(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ThirdLevel::class, DummyFriend::class, RelatedDummy::class, RelatedToDummyFriend::class]); + $this->seedBasics(); + + self::createClient()->request('POST', '/related_to_dummy_friends', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Friends relation', + 'dummyFriend' => '/dummy_friends/1', + 'relatedDummy' => '/related_dummies/1', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/RelatedToDummyFriend', + '@id' => '/related_to_dummy_friends/dummyFriend=1;relatedDummy=1', + '@type' => 'RelatedToDummyFriend', + 'name' => 'Friends relation', + 'description' => null, + 'dummyFriend' => [ + '@id' => '/dummy_friends/1', + '@type' => 'DummyFriend', + 'name' => 'Zoidberg', + ], + ]); + } + + public function testGetFriendRelationship(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ThirdLevel::class, DummyFriend::class, RelatedDummy::class, RelatedToDummyFriend::class]); + $this->seedBasics(); + self::createClient()->request('POST', '/related_to_dummy_friends', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Friends relation', + 'dummyFriend' => '/dummy_friends/1', + 'relatedDummy' => '/related_dummies/1', + ], + ]); + + self::createClient()->request('GET', '/related_to_dummy_friends/dummyFriend=1;relatedDummy=1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/RelatedToDummyFriend', + '@id' => '/related_to_dummy_friends/dummyFriend=1;relatedDummy=1', + '@type' => 'RelatedToDummyFriend', + 'name' => 'Friends relation', + 'description' => null, + 'dummyFriend' => [ + '@id' => '/dummy_friends/1', + '@type' => 'DummyFriend', + 'name' => 'Zoidberg', + ], + ]); + } + + public function testCreateDummyWithRelations(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, Dummy::class]); + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['level' => 3], + ]); + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['thirdLevel' => '/third_levels/1'], + ]); + + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Dummy with relations', + 'relatedDummy' => 'http://example.com/related_dummies/1', + 'relatedDummies' => ['/related_dummies/1'], + 'name_converted' => null, + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'name' => 'Dummy with relations', + 'relatedDummy' => '/related_dummies/1', + 'relatedDummies' => ['/related_dummies/1'], + ]); + } + + public function testFilterOnRelation(): void + { + $this->testCreateDummyWithRelations(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy=%2Frelated_dummies%2F1'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummies/1', $data['hydra:member'][0]['@id']); + } + + public function testFilterOnToManyRelation(): void + { + $this->testCreateDummyWithRelations(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies[]=%2Frelated_dummies%2F1'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummies/1', $data['hydra:member'][0]['@id']); + } + + public function testEmbedRelationInParent(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['level' => 3], + ]); + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['thirdLevel' => '/third_levels/1'], + ]); + + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['related' => '/related_dummies/1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/RelationEmbedder', + '@id' => '/relation_embedders/1', + '@type' => 'RelationEmbedder', + 'krondstadt' => 'Krondstadt', + 'anotherRelated' => null, + 'related' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'symfony', + 'thirdLevel' => [ + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'level' => 3, + 'fourthLevel' => null, + ], + ], + ]); + } + + public function testPostWrongRelationReturns400(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'anotherRelated' => [ + '@id' => '/related_dummies/123', + '@type' => 'https://schema.org/Product', + 'symfony' => 'phalcon', + ], + ], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testPostRelationWithNotExistingIriReturns400(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['related' => '/related_dummies/123'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testInvalidIriReturns400(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['related' => 'certainly not an IRI'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains(['detail' => 'Invalid IRI "certainly not an IRI".']); + } + + public function testInvalidTypeReturns400(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + + $response = self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['related' => 8], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $linkHeader = $response->getHeaders(false)['link'][0] ?? ''; + $this->assertStringContainsString('; rel="http://www.w3.org/ns/json-ld#error"', $linkHeader); + $data = $response->toArray(false); + $this->assertMatchesRegularExpression( + '/The type of the "ApiPlatform\\\\Tests\\\\Fixtures\\\\TestBundle\\\\(Document|Entity)\\\\RelatedDummy" resource must be "array" \(nested document\) or "string" \(IRI\), "integer" given\./', + $data['detail'] + ); + } + + public function testEagerLoadOrdersAreNotDuplicated(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Order/Customer/Address fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Order::class, Customer::class, Address::class]); + + $manager = $this->getManager(); + $customer = new Customer(); + $customer->name = 'customer_name'; + $a1 = new Address(); + $a1->name = 'foo'; + $a2 = new Address(); + $a2->name = 'bar'; + $customer->addresses->add($a1); + $customer->addresses->add($a2); + $manager->persist($a1); + $manager->persist($a2); + $manager->persist($customer); + $manager->flush(); + + $order = new Order(); + $order->customer = $customer; + $order->recipient = $customer; + $manager->persist($order); + $manager->flush(); + + self::createClient()->request('GET', '/orders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/Order', + '@id' => '/orders', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/orders/1', + '@type' => 'Order', + 'id' => 1, + 'customer' => [ + '@id' => '/customers/1', + '@type' => 'Customer', + 'id' => 1, + 'name' => 'customer_name', + 'addresses' => [ + ['@id' => '/addresses/1', '@type' => 'Address', 'id' => 1, 'name' => 'foo'], + ['@id' => '/addresses/2', '@type' => 'Address', 'id' => 2, 'name' => 'bar'], + ], + ], + 'recipient' => [ + '@id' => '/customers/1', + '@type' => 'Customer', + 'id' => 1, + 'name' => 'customer_name', + 'addresses' => [ + ['@id' => '/addresses/1', '@type' => 'Address', 'id' => 1, 'name' => 'foo'], + ['@id' => '/addresses/2', '@type' => 'Address', 'id' => 2, 'name' => 'bar'], + ], + ], + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testIssue1222PeopleWithPets(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Person/Pet/PersonToPet fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Person::class, Pet::class, PersonToPet::class]); + + $manager = $this->getManager(); + $person = new Person(); + $person->name = 'foo'; + $manager->persist($person); + $pet = new Pet(); + $pet->name = 'bar'; + $manager->persist($pet); + $manager->flush(); + $personToPet = new PersonToPet(); + $personToPet->person = $person; + $personToPet->pet = $pet; + $manager->persist($personToPet); + $manager->flush(); + $manager->clear(); + + self::createClient()->request('GET', '/people', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@context' => '/contexts/Person', + '@id' => '/people', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/people/1', + '@type' => 'Person', + 'name' => 'foo', + 'pets' => [[ + '@type' => 'PersonToPet', + 'pet' => [ + '@id' => '/pets/1', + '@type' => 'Pet', + 'name' => 'bar', + ], + ]], + ]], + 'hydra:totalItems' => 1, + ]); + } +} diff --git a/tests/Functional/StandardPutTest.php b/tests/Functional/StandardPutTest.php new file mode 100644 index 00000000000..e3910a89c94 --- /dev/null +++ b/tests/Functional/StandardPutTest.php @@ -0,0 +1,189 @@ + + * + * 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\StandardPut; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UidIdentified; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class StandardPutTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [StandardPut::class, UidIdentified::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('UidIdentified fixture has no MongoDB document twin.'); + } + + $this->recreateSchema([StandardPut::class, UidIdentified::class]); + } + + public function testCreateWithPut(): void + { + self::createClient()->request('PUT', '/standard_puts/5', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'a', 'bar' => 'b'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/StandardPut', + '@id' => '/standard_puts/5', + '@type' => 'StandardPut', + 'id' => 5, + 'foo' => 'a', + 'bar' => 'b', + ]); + } + + public function testCreateWithPutAndJsonLdAttributes(): void + { + self::createClient()->request('PUT', '/standard_puts/6', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/standard_puts/6', + '@context' => '/contexts/StandardPut', + '@type' => 'StandardPut', + 'foo' => 'a', + 'bar' => 'b', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/StandardPut', + '@id' => '/standard_puts/6', + '@type' => 'StandardPut', + 'id' => 6, + 'foo' => 'a', + 'bar' => 'b', + ]); + } + + public function testFailsWhenJsonLdIdRefersToWrongResource(): void + { + self::createClient()->request('PUT', '/standard_puts/7', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/dummies/6', + '@context' => '/contexts/StandardPut', + '@type' => 'StandardPut', + 'foo' => 'a', + 'bar' => 'b', + ], + ]); + + $this->assertResponseStatusCodeSame(400); + } + + public function testFailsWhenJsonLdIdDoesNotMatchUri(): void + { + self::createClient()->request('PUT', '/standard_puts/7', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/standard_puts/6', + '@context' => '/contexts/StandardPut', + '@type' => 'StandardPut', + 'foo' => 'a', + 'bar' => 'b', + ], + ]); + + $this->assertResponseStatusCodeSame(400); + } + + public function testReplaceExistingWithPut(): void + { + self::createClient()->request('PUT', '/standard_puts/5', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'a', 'bar' => 'b'], + ]); + + self::createClient()->request('PUT', '/standard_puts/5', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'c'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/StandardPut', + '@id' => '/standard_puts/5', + '@type' => 'StandardPut', + 'id' => 5, + 'foo' => 'c', + 'bar' => '', + ]); + } + + public function testCreateWithPutAndUidIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('PUT', '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'test'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/UidIdentified', + '@id' => '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', + '@type' => 'UidIdentified', + 'id' => 'fbcf5910-d915-4f7d-ba39-6b2957c57335', + 'name' => 'test', + ]); + } + + public function testReplaceExistingUidIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('PUT', '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'test'], + ]); + + self::createClient()->request('PUT', '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'bar'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/UidIdentified', + '@id' => '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', + '@type' => 'UidIdentified', + 'id' => 'fbcf5910-d915-4f7d-ba39-6b2957c57335', + 'name' => 'bar', + ]); + } +} diff --git a/tests/Functional/SubResource/SubResourceTest.php b/tests/Functional/SubResource/SubResourceTest.php new file mode 100644 index 00000000000..45d79665fae --- /dev/null +++ b/tests/Functional/SubResource/SubResourceTest.php @@ -0,0 +1,596 @@ + + * + * 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\ApiResource\SubresourceBike; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SubresourceCategory; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Answer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyOffer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Greeting; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OneToOneSubresourceAnswer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OneToOneSubresourceQuestion; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SubresourceEmployee; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SubresourceFactory; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SubresourceOrganization; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class SubResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Question::class, + Answer::class, + OneToOneSubresourceQuestion::class, + OneToOneSubresourceAnswer::class, + FourthLevel::class, + ThirdLevel::class, + RelatedDummy::class, + Dummy::class, + RelatedOwnedDummy::class, + RelatedOwningDummy::class, + DummyProduct::class, + DummyAggregateOffer::class, + DummyOffer::class, + Person::class, + Greeting::class, + SubresourceOrganization::class, + SubresourceEmployee::class, + SubresourceFactory::class, + SubresourceCategory::class, + SubresourceBike::class, + ]; + } + + private function seedAnswerToQuestion(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Subresource Question/Answer fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Question::class, Answer::class]); + + $manager = $this->getManager(); + $answer = new Answer(); + $answer->setContent('42'); + + $question = new Question(); + $question->setContent("What's the answer to the Ultimate Question of Life, the Universe and Everything?"); + $question->setAnswer($answer); + $answer->addRelatedQuestion($question); + + $manager->persist($answer); + $manager->persist($question); + $manager->flush(); + $manager->clear(); + } + + private function seedOneToOneSubresource(): void + { + $this->recreateSchema([OneToOneSubresourceQuestion::class, OneToOneSubresourceAnswer::class]); + + $manager = $this->getManager(); + $answer = new OneToOneSubresourceAnswer(); + $answer->setContent('42'); + + $question = new OneToOneSubresourceQuestion(); + $question->setContent("What's the answer to the Ultimate Question of Life, the Universe and Everything?"); + $question->setAnswer($answer); + $answer->setQuestion($question); + + $manager->persist($answer); + $manager->persist($question); + $manager->flush(); + $manager->clear(); + } + + private function seedDummyWithFourthLevel(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Nested subresource fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]); + + $manager = $this->getManager(); + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $named = new RelatedDummy(); + $named->setName('Hello'); + $named->setThirdLevel($thirdLevel); + $manager->persist($named); + + $other = new RelatedDummy(); + $other->setThirdLevel($thirdLevel); + $manager->persist($other); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($named); + $dummy->addRelatedDummy($named); + $dummy->addRelatedDummy($other); + $manager->persist($dummy); + + $manager->flush(); + } + + public function testGetOneToOneSubResource(): void + { + $this->seedAnswerToQuestion(); + + $response = self::createClient()->request('GET', '/questions/1/answer', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Answer', + '@id' => '/questions/1/answer', + '@type' => 'Answer', + 'id' => 1, + 'content' => '42', + 'relatedQuestions' => ['/questions/1'], + ]); + } + + public function testOneToOneSubresourceExposesInverseSideBackIri(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('OneToOneSubresource fixtures are ORM-only.'); + } + + $this->seedOneToOneSubresource(); + + self::createClient()->request('GET', '/one_to_one_subresource_questions/1/answer', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/OneToOneSubresourceAnswer', + '@id' => '/one_to_one_subresource_questions/1/answer', + '@type' => 'OneToOneSubresourceAnswer', + 'id' => 1, + 'content' => '42', + 'question' => '/one_to_one_subresource_questions/1', + ]); + } + + public function testGetNonExistentSubResourceReturns404(): void + { + $this->seedAnswerToQuestion(); + + self::createClient()->request('GET', '/questions/999999/answer'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetRecursiveSubResource(): void + { + $this->seedAnswerToQuestion(); + + self::createClient()->request('GET', '/questions/1/answer/related_questions', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/Question', + '@id' => '/questions/1/answer/related_questions', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/questions/1', + '@type' => 'Question', + 'content' => "What's the answer to the Ultimate Question of Life, the Universe and Everything?", + 'id' => 1, + 'answer' => '/answers/1', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testGetSubResourceCollection(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request('GET', '/dummies/1/related_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/dummies/1/related_dummies', $data['@id']); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('/related_dummies/1', $data['hydra:member'][0]['@id']); + $this->assertSame('Hello', $data['hydra:member'][0]['name']); + $this->assertSame('/related_dummies/2', $data['hydra:member'][1]['@id']); + $this->assertNull($data['hydra:member'][1]['name']); + } + + public function testGetFilteredSubResourceCollection(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request('GET', '/dummies/1/related_dummies?name=Hello'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('Hello', $data['hydra:member'][0]['name']); + } + + public function testGetSubResourceItem(): void + { + $this->seedDummyWithFourthLevel(); + + self::createClient()->request('GET', '/dummies/1/related_dummies/2'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/RelatedDummy', + '@id' => '/dummies/1/related_dummies/2', + '@type' => 'https://schema.org/Product', + 'id' => 2, + 'name' => null, + ]); + } + + public function testCreateDummyWithSubResourceRelation(): void + { + $this->seedDummyWithFourthLevel(); + + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Dummy with relations', 'relatedDummy' => '/dummies/1/related_dummies/2'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testGetEmbeddedRelationAtThirdLevel(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request('GET', '/dummies/1/related_dummies/1/third_level'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('/contexts/ThirdLevel', $data['@context']); + $this->assertSame('/dummies/1/related_dummies/1/third_level', $data['@id']); + $this->assertSame('ThirdLevel', $data['@type']); + $this->assertSame('/fourth_levels/1', $data['fourthLevel']); + $this->assertSame(1, $data['id']); + $this->assertSame(3, $data['level']); + $this->assertTrue($data['test']); + } + + public function testGetEmbeddedRelationAtFourthLevel(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request('GET', '/dummies/1/related_dummies/1/third_level/fourth_level', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/FourthLevel', + '@id' => '/dummies/1/related_dummies/1/third_level/fourth_level', + '@type' => 'FourthLevel', + 'badThirdLevel' => [], + 'id' => 1, + 'level' => 4, + ]); + } + + private function seedProductWithOffers(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Product/Offer fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([DummyProduct::class, DummyAggregateOffer::class, DummyOffer::class]); + + $manager = $this->getManager(); + $offer = new DummyOffer(); + $offer->setId(1); + $offer->setValue(2); + + $aggregate = new DummyAggregateOffer(); + $aggregate->setValue(1); + $aggregate->addOffer($offer); + + $product = new DummyProduct(); + $product->setId(2); + $product->setName('Dummy product'); + $product->addOffer($aggregate); + + $relatedProduct = new DummyProduct(); + $relatedProduct->setName('Dummy related product'); + $relatedProduct->setId(1); + $relatedProduct->setParent($product); + $product->addRelatedProduct($relatedProduct); + + $manager->persist($offer); + $manager->persist($aggregate); + $manager->persist($product); + $manager->persist($relatedProduct); + $manager->flush(); + } + + public function testGetOffersFromAggregateOffers(): void + { + $this->seedProductWithOffers(); + + self::createClient()->request('GET', '/dummy_products/2/offers/1/offers'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/DummyOffer', + '@id' => '/dummy_products/2/offers/1/offers', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/dummy_offers/1', + '@type' => 'DummyOffer', + 'id' => 1, + 'value' => 2, + 'aggregate' => '/dummy_aggregate_offers/1', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testGetOffersFromAggregateOffersDirect(): void + { + $this->seedProductWithOffers(); + + self::createClient()->request('GET', '/dummy_aggregate_offers/1/offers'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/DummyOffer', + '@id' => '/dummy_aggregate_offers/1/offers', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/dummy_offers/1', + '@type' => 'DummyOffer', + 'id' => 1, + 'value' => 2, + 'aggregate' => '/dummy_aggregate_offers/1', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testRecursiveResource(): void + { + $this->seedProductWithOffers(); + + self::createClient()->request('GET', '/dummy_products/2'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/DummyProduct', + '@id' => '/dummy_products/2', + '@type' => 'DummyProduct', + 'offers' => ['/dummy_aggregate_offers/1'], + 'id' => 2, + 'name' => 'Dummy product', + 'relatedProducts' => ['/dummy_products/1'], + 'parent' => null, + ]); + } + + public function testPersonSentGreetings(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Person/Greeting fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Person::class, Greeting::class]); + + $manager = $this->getManager(); + $person = new Person(); + $person->name = 'Alice'; + + $greeting = new Greeting(); + $greeting->message = 'hello'; + $greeting->sender = $person; + $manager->persist($person); + $manager->persist($greeting); + $manager->flush(); + + self::createClient()->request('GET', '/people/1/sent_greetings'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Greeting', + '@id' => '/people/1/sent_greetings', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/greetings/1', + '@type' => 'Greeting', + 'message' => 'hello', + 'sender' => '/people/1', + 'recipient' => null, + 'id' => 1, + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testOneToOneFromOwnedSide(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('RelatedOwnedDummy fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Dummy::class, RelatedOwnedDummy::class]); + + $manager = $this->getManager(); + $relatedOwned = new RelatedOwnedDummy(); + $manager->persist($relatedOwned); + + $dummy = new Dummy(); + $dummy->setName('plop'); + $dummy->setRelatedOwnedDummy($relatedOwned); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/related_owned_dummies/1/owning_dummy'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@context' => '/contexts/Dummy', + '@id' => '/related_owned_dummies/1/owning_dummy', + '@type' => 'Dummy', + 'name' => 'plop', + 'relatedOwnedDummy' => '/related_owned_dummies/1', + 'relatedOwningDummy' => null, + 'id' => 1, + ]); + } + + public function testOneToOneFromOwningSide(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('RelatedOwningDummy fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Dummy::class, RelatedOwningDummy::class]); + + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('plop'); + $manager->persist($dummy); + + $relatedOwning = new RelatedOwningDummy(); + $relatedOwning->setOwnedDummy($dummy); + $manager->persist($relatedOwning); + $manager->flush(); + + self::createClient()->request('GET', '/related_owning_dummies/1/owned_dummy'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@context' => '/contexts/Dummy', + '@id' => '/related_owning_dummies/1/owned_dummy', + '@type' => 'Dummy', + 'name' => 'plop', + 'relatedOwningDummy' => '/related_owning_dummies/1', + 'relatedOwnedDummy' => null, + 'id' => 1, + ]); + } + + public static function subresourceCrudUris(): iterable + { + yield 'employees' => [ + '/subresource_organizations/invalid/subresource_employees', + '/subresource_organizations/1/subresource_employees', + '/subresource_organizations/1/subresource_employees/1', + ]; + yield 'factories' => [ + '/subresource_organizations/invalid/subresource_factories', + '/subresource_organizations/1/subresource_factories', + '/subresource_organizations/1/subresource_factories/1', + ]; + } + + #[DataProvider('subresourceCrudUris')] + public function testGeneratedSubresourceCrud(string $invalidUri, string $collectionUri, string $itemUri): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SubresourceOrganization::class, SubresourceEmployee::class, SubresourceFactory::class]); + + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + + $client->request('POST', '/subresource_organizations', ['headers' => $headers, 'json' => ['name' => 'Les Tilleuls']]); + $this->assertResponseStatusCodeSame(201); + + $client->request('POST', $invalidUri, ['headers' => $headers, 'json' => ['name' => 'soyuka']]); + $this->assertResponseStatusCodeSame(404); + + $client->request('POST', $collectionUri, ['headers' => $headers, 'json' => ['name' => 'soyuka']]); + $this->assertResponseStatusCodeSame(201); + + $client->request('GET', $itemUri); + $this->assertResponseStatusCodeSame(200); + + $client->request('GET', $collectionUri); + $this->assertResponseStatusCodeSame(200); + + $client->request('PUT', $itemUri, ['headers' => $headers, 'json' => ['name' => 'ok']]); + $this->assertResponseStatusCodeSame(200); + + $client->request('DELETE', $itemUri); + $this->assertResponseStatusCodeSame(204); + } + + public function testCreateProviderSubresource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('POST', '/subresource_categories/1/subresource_bikes', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello World!'], + ]); + $this->assertResponseStatusCodeSame(404); + + self::createClient()->request('POST', '/subresource_categories_with_create_provider/1/subresource_bikes', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello World!'], + ]); + $this->assertResponseStatusCodeSame(201); + } +} diff --git a/tests/Functional/TableInheritanceTest.php b/tests/Functional/TableInheritanceTest.php new file mode 100644 index 00000000000..20e0189f116 --- /dev/null +++ b/tests/Functional/TableInheritanceTest.php @@ -0,0 +1,301 @@ + + * + * 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\AbstractUser; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceDifferentChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ExternalUser; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InternalUser; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Site; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\ResourceInterface; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class TableInheritanceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceRelated::class, + ResourceInterface::class, + Site::class, + AbstractUser::class, + ExternalUser::class, + ]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Table inheritance fixtures are ORM-only.'); + } + + $this->recreateSchema([ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceNotApiResourceChild::class, + DummyTableInheritanceRelated::class, + Site::class, + InternalUser::class, + ExternalUser::class, + ]); + } + + private function createChild(string $name = 'foo', string $nickname = 'bar'): array + { + $response = self::createClient()->request('POST', '/dummy_table_inheritance_children', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => $name, 'nickname' => $nickname], + ]); + + return $response->toArray(); + } + + public function testCreateChildResource(): void + { + $data = $this->createChild(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertSame('DummyTableInheritanceChild', $data['@type']); + $this->assertSame('/contexts/DummyTableInheritanceChild', $data['@context']); + $this->assertSame('/dummy_table_inheritance_children/1', $data['@id']); + $this->assertSame('foo', $data['name']); + $this->assertSame('bar', $data['nickname']); + } + + public function testParentCollectionExposesChildren(): void + { + $this->createChild(); + + $response = self::createClient()->request('GET', '/dummy_table_inheritances'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('DummyTableInheritanceChild', $data['hydra:member'][0]['@type']); + $this->assertSame('/dummy_table_inheritance_children/1', $data['hydra:member'][0]['@id']); + } + + public function testNonApiResourceChildAppearsAsParent(): void + { + $this->createChild(); + $manager = $this->getManager(); + $notApi = new DummyTableInheritanceNotApiResourceChild(); + $notApi->setName('Foobarbaz inheritance'); + $manager->persist($notApi); + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_table_inheritances'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $this->assertSame('DummyTableInheritanceChild', $data['hydra:member'][0]['@type']); + $this->assertSame('DummyTableInheritance', $data['hydra:member'][1]['@type']); + $this->assertSame('/dummy_table_inheritances/2', $data['hydra:member'][1]['@id']); + $this->assertSame(2, $data['hydra:totalItems']); + } + + public function testCreateDifferentChildResource(): void + { + $response = self::createClient()->request('POST', '/dummy_table_inheritance_different_children', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo', 'email' => 'bar@localhost'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('DummyTableInheritanceDifferentChild', $data['@type']); + $this->assertSame('/contexts/DummyTableInheritanceDifferentChild', $data['@context']); + $this->assertSame('foo', $data['name']); + $this->assertSame('bar@localhost', $data['email']); + } + + public function testRelatedEntityWithMixedInheritedChildren(): void + { + $child = $this->createChild(); + $different = self::createClient()->request('POST', '/dummy_table_inheritance_different_children', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo', 'email' => 'bar@localhost'], + ])->toArray(); + + $response = self::createClient()->request('POST', '/dummy_table_inheritance_relateds', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['children' => [$child['@id'], $different['@id']]], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('DummyTableInheritanceRelated', $data['@type']); + $this->assertSame('/dummy_table_inheritance_relateds/1', $data['@id']); + $this->assertCount(2, $data['children']); + $this->assertSame('DummyTableInheritanceChild', $data['children'][0]['@type']); + $this->assertSame('DummyTableInheritanceDifferentChild', $data['children'][1]['@type']); + } + + public function testParentCollectionMixesChildrenTypes(): void + { + $this->createChild('foo', 'bar'); + $manager = $this->getManager(); + $notApi = new DummyTableInheritanceNotApiResourceChild(); + $notApi->setName('Foobarbaz inheritance'); + $manager->persist($notApi); + $manager->flush(); + $this->createChild('foo2', 'bar2'); + self::createClient()->request('POST', '/dummy_table_inheritance_different_children', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo', 'email' => 'bar@localhost'], + ]); + + $response = self::createClient()->request('GET', '/dummy_table_inheritances?pagination=false'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(4, $data['hydra:totalItems']); + $types = array_column($data['hydra:member'], '@type'); + $this->assertContains('DummyTableInheritanceChild', $types); + $this->assertContains('DummyTableInheritance', $types); + $this->assertContains('DummyTableInheritanceDifferentChild', $types); + } + + public function testInterfaceCollection(): void + { + $response = self::createClient()->request('GET', '/resource_interfaces', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $members = $data['hydra:member']; + $this->assertCount(2, $members); + $this->assertSame('ResourceInterface', $members[0]['@type']); + $this->assertSame('/resource_interfaces/item1', $members[0]['@id']); + $this->assertSame('item1', $members[0]['foo']); + $this->assertSame('fooz', $members[0]['fooz']); + $this->assertSame('ResourceInterface', $members[1]['@type']); + $this->assertSame('/resource_interfaces/item2', $members[1]['@id']); + $this->assertSame('item2', $members[1]['foo']); + $this->assertSame('fooz', $members[1]['fooz']); + } + + public function testInterfaceItem(): void + { + $response = self::createClient()->request('GET', '/resource_interfaces/some-id', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/ResourceInterface', $data['@context']); + $this->assertSame('/resource_interfaces/single%20item', $data['@id']); + $this->assertSame('ResourceInterface', $data['@type']); + $this->assertSame('single item', $data['foo']); + $this->assertSame('fooz', $data['fooz']); + } + + public function testSitesWithInternalOwnerUseParentIri(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $user = new InternalUser(); + $user->setFirstname('Internal'); + $user->setLastname('User'); + $user->setEmail('john.doe@example.com'); + $user->setInternalId('INT'); + $site = new Site(); + $site->setTitle('title'); + $site->setDescription('description'); + $site->setOwner($user); + $manager->persist($site); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/sites', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertCount(3, $data['hydra:member']); + foreach ($data['hydra:member'] as $i => $member) { + $this->assertSame('Site', $member['@type']); + $this->assertSame('/sites/'.($i + 1), $member['@id']); + $this->assertSame('title', $member['title']); + $this->assertSame('description', $member['description']); + $ownerIri = \is_string($member['owner']) ? $member['owner'] : $member['owner']['@id']; + $this->assertSame('/custom_users/'.($i + 1), $ownerIri); + } + } + + public function testSitesWithExternalOwnerUseCurrentResourceIri(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $user = new ExternalUser(); + $user->setFirstname('External'); + $user->setLastname('User'); + $user->setEmail('john.doe@example.com'); + $user->setExternalId('EXT'); + $site = new Site(); + $site->setTitle('title'); + $site->setDescription('description'); + $site->setOwner($user); + $manager->persist($site); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/sites', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + foreach ($data['hydra:member'] as $i => $member) { + $ownerIri = \is_string($member['owner']) ? $member['owner'] : $member['owner']['@id']; + $this->assertSame('/external_users/'.($i + 1), $ownerIri); + } + } +} diff --git a/tests/Functional/UnionIntersectTypesTest.php b/tests/Functional/UnionIntersectTypesTest.php new file mode 100644 index 00000000000..3d04fe4ad47 --- /dev/null +++ b/tests/Functional/UnionIntersectTypesTest.php @@ -0,0 +1,103 @@ + + * + * 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\ApiResource\Issue5452\Author; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Library; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class UnionIntersectTypesTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Book::class, Author::class, Library::class]; + } + + public function testCreateBookWithUnionTypeNumberAsString(): void + { + $response = self::createClient()->request('POST', '/issue-5452/books', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['number' => '1', 'isbn' => '978-3-16-148410-0'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('Book', $data['@type']); + $this->assertSame('/contexts/Book', $data['@context']); + $this->assertMatchesRegularExpression('#^/.well-known/genid/.+$#', $data['@id']); + $this->assertSame('1', $data['number']); + $this->assertSame('978-3-16-148410-0', $data['isbn']); + } + + public function testCreateBookWithUnionTypeNumberAsInteger(): void + { + $response = self::createClient()->request('POST', '/issue-5452/books', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['number' => 1, 'isbn' => '978-3-16-148410-0'], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + $this->assertSame(1, $data['number']); + $this->assertSame('978-3-16-148410-0', $data['isbn']); + } + + public function testCreateBookWithValidIntersectType(): void + { + $response = self::createClient()->request('POST', '/issue-5452/books', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'number' => 1, + 'isbn' => '978-3-16-148410-0', + 'author' => '/issue-5452/authors/1', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('Book', $data['@type']); + $this->assertSame(1, $data['number']); + $this->assertSame('978-3-16-148410-0', $data['isbn']); + $this->assertSame('/issue-5452/authors/1', $data['author']); + } + + public function testCreateBookWithInvalidIntersectTypeReturns400(): void + { + self::createClient()->request('POST', '/issue-5452/books', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'number' => 1, + 'isbn' => '978-3-16-148410-0', + 'library' => '/issue-5452/libraries/1', + ], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Could not denormalize object of type "ApiPlatform\\Tests\\Fixtures\\TestBundle\\ApiResource\\Issue5452\\ActivableInterface", no supporting normalizer found.', + ]); + } +} diff --git a/tests/Functional/UrlEncodedIdTest.php b/tests/Functional/UrlEncodedIdTest.php new file mode 100644 index 00000000000..aba8e157671 --- /dev/null +++ b/tests/Functional/UrlEncodedIdTest.php @@ -0,0 +1,73 @@ + + * + * 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\UrlEncodedId; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class UrlEncodedIdTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [UrlEncodedId::class]; + } + + public static function urlVariants(): iterable + { + yield 'raw colon and percent' => ['/url_encoded_ids/%encode:id']; + yield 'fully encoded' => ['/url_encoded_ids/%25encode%3Aid']; + yield 'encoded percent only' => ['/url_encoded_ids/%25encode:id']; + yield 'encoded colon only' => ['/url_encoded_ids/%encode%3Aid']; + } + + #[DataProvider('urlVariants')] + public function testGetEncodedIdWhetherOrNotEncoded(string $url): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('UrlEncodedId fixture is ORM-only.'); + } + + $this->recreateSchema([UrlEncodedId::class]); + + $client = self::createClient(); + $manager = $this->getManager(); + $entity = new UrlEncodedId(); + $manager->persist($entity); + $manager->flush(); + $manager->clear(); + + $client->request('GET', $url, [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/UrlEncodedId', + '@id' => '/url_encoded_ids/%25encode:id', + '@type' => 'UrlEncodedId', + 'id' => '%encode:id', + ]); + } +} diff --git a/tests/Functional/Uuid/UuidIdentifierTest.php b/tests/Functional/Uuid/UuidIdentifierTest.php new file mode 100644 index 00000000000..943131ccf3c --- /dev/null +++ b/tests/Functional/Uuid/UuidIdentifierTest.php @@ -0,0 +1,302 @@ + + * + * 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\Uuid; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomGeneratedIdentifier; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Ramsey\Uuid\Uuid as RamseyUuid; +use Symfony\Component\Uid\Uuid as SymfonyUuid; + +final class UuidIdentifierTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [UuidIdentifierDummy::class, RamseyUuidDummy::class, CustomGeneratedIdentifier::class, SymfonyUuidDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([UuidIdentifierDummy::class, CustomGeneratedIdentifier::class]); + } + + private function createUuidDummy(): void + { + self::createClient()->request('POST', '/uuid_identifier_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy', 'uuid' => '41b29566-144b-11e6-a148-3e1d05defe78'], + ]); + } + + public function testCreateUuidIdentifier(): void + { + $this->createUuidDummy(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78.jsonld'); + $this->assertResponseHeaderSame('Location', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78'); + } + + public function testGetUuidItem(): void + { + $this->createUuidDummy(); + + self::createClient()->request('GET', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/UuidIdentifierDummy', + '@id' => '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78', + '@type' => 'UuidIdentifierDummy', + 'uuid' => '41b29566-144b-11e6-a148-3e1d05defe78', + 'name' => 'My Dummy', + ]); + } + + public function testGetUuidCollection(): void + { + $this->createUuidDummy(); + + self::createClient()->request('GET', '/uuid_identifier_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/UuidIdentifierDummy', + '@id' => '/uuid_identifier_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78', + '@type' => 'UuidIdentifierDummy', + 'uuid' => '41b29566-144b-11e6-a148-3e1d05defe78', + 'name' => 'My Dummy', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testPutUuidIdentifier(): void + { + $this->createUuidDummy(); + + self::createClient()->request('PUT', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/UuidIdentifierDummy', + '@id' => '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78', + '@type' => 'UuidIdentifierDummy', + 'uuid' => '41b29566-144b-11e6-a148-3e1d05defe78', + 'name' => 'My Dummy modified', + ]); + } + + public function testCustomGeneratedIdentifier(): void + { + self::createClient()->request('POST', '/custom_generated_identifiers', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Location', '/custom_generated_identifiers/foo.jsonld'); + $this->assertResponseHeaderSame('Location', '/custom_generated_identifiers/foo'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomGeneratedIdentifier', + '@id' => '/custom_generated_identifiers/foo', + '@type' => 'CustomGeneratedIdentifier', + 'id' => 'foo', + ]); + } + + public function testDeleteUuid(): void + { + $this->createUuidDummy(); + + self::createClient()->request('DELETE', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testGetRamseyUuidDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new RamseyUuidDummy(RamseyUuid::fromString('41B29566-144B-11E6-A148-3E1D05DEFE78')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/ramsey_uuid_dummies/41B29566-144B-11E6-A148-3E1D05DEFE78'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testDeleteRamseyUuidDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new RamseyUuidDummy(RamseyUuid::fromString('41B29566-144B-11E6-A148-3E1D05DEFE78')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('DELETE', '/ramsey_uuid_dummies/41B29566-144B-11E6-A148-3E1D05DEFE78'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testRetrieveBadRamseyUuidReturns404(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/ramsey_uuid_dummies/41B29566-144B-E1D05DEFE78'); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testCreateRamseyUuidDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + self::createClient()->request('POST', '/ramsey_uuid_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['id' => '41b29566-144b-11e6-a148-3e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testCreateRamseyUuidDummyWithNonIdField(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + self::createClient()->request('POST', '/ramsey_uuid_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['other' => '51b29566-144b-11e6-a148-3e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testUpdateRamseyUuidNonIdField(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new RamseyUuidDummy(RamseyUuid::fromString('41b29566-144b-11e6-a148-3e1d05defe78')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('PUT', '/ramsey_uuid_dummies/41b29566-144b-11e6-a148-3e1d05defe78', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['other' => '61b29566-144b-11e6-a148-3e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testCreateBadRamseyUuidReturns400(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + self::createClient()->request('POST', '/ramsey_uuid_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['id' => '41b29566-144b-e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testUpdateBadRamseyUuidReturns400(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new RamseyUuidDummy(RamseyUuid::fromString('41b29566-144b-11e6-a148-3e1d05defe78')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('PUT', '/ramsey_uuid_dummies/41b29566-144b-11e6-a148-3e1d05defe78', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['other' => '61b29566-144b-e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testGetSymfonyUuidDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([SymfonyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new SymfonyUuidDummy(SymfonyUuid::fromString('cdf8f706-ebe3-4fb6-b0bd-ae7b48028f24')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/symfony_uuid_dummies/cdf8f706-ebe3-4fb6-b0bd-ae7b48028f24'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } +} diff --git a/tests/Functional/ValidationGroupsTest.php b/tests/Functional/ValidationGroupsTest.php new file mode 100644 index 00000000000..ee2bc3cb5a5 --- /dev/null +++ b/tests/Functional/ValidationGroupsTest.php @@ -0,0 +1,131 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity\DummyValidation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyValidationSerializedName; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5912\Dummy as Issue5912Dummy; + +final class ValidationGroupsTest extends \ApiPlatform\Symfony\Bundle\Test\ApiTestCase +{ + use \ApiPlatform\Tests\RecreateSchemaTrait; + use \ApiPlatform\Tests\SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyValidation::class, DummyValidationSerializedName::class, Issue5912Dummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([DummyValidation::class, DummyValidationSerializedName::class]); + } + + public function testCreateMinimalResourceWithoutGroups(): void + { + self::createClient()->request('POST', '/dummy_validation', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testValidationGroupsTriggerFailure(): void + { + self::createClient()->request('POST', '/dummy_validation/validation_groups', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/ConstraintViolation', + '@type' => 'ConstraintViolation', + 'detail' => 'name: This value should not be null.', + 'violations' => [[ + 'propertyPath' => 'name', + 'message' => 'This value should not be null.', + 'code' => 'ad32d13f-c3d4-423b-909a-857b961eb720', + ]], + ]); + } + + public function testValidationGroupSequence(): void + { + self::createClient()->request('POST', '/dummy_validation/validation_sequence', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'title: This value should not be null.', + 'violations' => [[ + 'propertyPath' => 'title', + 'message' => 'This value should not be null.', + 'code' => 'ad32d13f-c3d4-423b-909a-857b961eb720', + ]], + ]); + } + + public function testValidationUsesSerializedNameForPropertyPath(): void + { + $response = self::createClient()->request('POST', '/dummy_validation_serialized_name', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $data = $response->toArray(false); + $this->assertSame('test: This value should not be null.', $data['detail']); + $this->assertSame('test', $data['violations'][0]['propertyPath']); + $this->assertSame('This value should not be null.', $data['violations'][0]['message']); + } + + public function testGetViolationConstraints(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('POST', '/issue5912s', [ + 'headers' => ['Accept' => 'application/json', 'Content-Type' => 'application/json'], + 'json' => ['title' => ''], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonEquals([ + 'status' => 422, + 'violations' => [[ + 'propertyPath' => 'title', + 'message' => 'This value should not be blank.', + 'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ]], + 'detail' => 'title: This value should not be blank.', + 'type' => '/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', + 'title' => 'An error occurred', + ]); + } +} From 9024c4176b880397971b8ecd7c98be4a0109e245 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 28 May 2026 14:58:28 +0200 Subject: [PATCH 4/7] test: migrate elasticsearch/security/serializer/mongodb behat suites to ApiTestCase (#8202) (cherry picked from commit d44867d0b0eeb59f7e6d5cbb2b6398f83326c67a) --- .github/workflows/ci.yml | 14 +- behat.yml.dist | 43 - composer.json | 4 +- ...embed_many_without_target_document.feature | 60 -- features/mongodb/filters.feature | 27 - features/security/README.md | 108 --- .../security/send_security_headers.feature | 32 - features/security/strong_typing.feature | 162 ---- features/security/unknown_attributes.feature | 43 - .../validate_incoming_content-types.feature | 17 - .../security/validate_response_types.feature | 38 - ...erialize_objects_using_constructor.feature | 41 - features/serializer/dynamic_groups.feature | 11 - .../serializer/empty_array_as_object.feature | 33 - features/serializer/groups_related.feature | 17 - features/serializer/vo_relations.feature | 209 ----- tests/Behat/ElasticsearchContext.php | 143 ---- .../app/config/config_elasticsearch.yml | 7 - .../Fixtures/app/config/config_opensearch.yml | 7 - .../Elasticsearch/ElasticsearchSetupTrait.php | 112 +++ .../Elasticsearch/MatchFilterTest.php | 239 ++++-- .../Elasticsearch/OrderFilterTest.php | 304 ++++--- .../Functional/Elasticsearch/ReadTest.php | 244 ++++-- .../Elasticsearch/TermFilterTest.php | 352 ++++---- .../EmbedManyWithoutTargetDocumentTest.php | 70 ++ .../NestedReferenceFilterErrorTest.php | 99 +++ .../Security/ContentNegotiationErrorsTest.php | 110 +++ .../Security/SecurityHeadersTest.php | 79 ++ .../Functional/Security/StrongTypingTest.php | 224 ++++++ .../ConstructorDeserializationTest.php | 65 ++ .../Serializer/DynamicGroupsTest.php | 49 ++ .../Serializer/EmptyArrayAsObjectTest.php | 52 ++ .../Functional/Serializer/GroupFilterTest.php | 754 ++++++++++++------ .../Serializer/GroupsRelatedTest.php | 67 ++ .../Serializer/PropertyFilterTest.php | 367 ++++++--- .../Serializer/ValueObjectRelationsTest.php | 270 +++++++ tools/feature_to_phpunit.php | 219 +++++ 37 files changed, 2886 insertions(+), 1806 deletions(-) delete mode 100644 features/mongodb/deserialize_embed_many_without_target_document.feature delete mode 100644 features/mongodb/filters.feature delete mode 100644 features/security/README.md delete mode 100644 features/security/send_security_headers.feature delete mode 100644 features/security/strong_typing.feature delete mode 100644 features/security/unknown_attributes.feature delete mode 100644 features/security/validate_incoming_content-types.feature delete mode 100644 features/security/validate_response_types.feature delete mode 100644 features/serializer/deserialize_objects_using_constructor.feature delete mode 100644 features/serializer/dynamic_groups.feature delete mode 100644 features/serializer/empty_array_as_object.feature delete mode 100644 features/serializer/groups_related.feature delete mode 100644 features/serializer/vo_relations.feature delete mode 100644 tests/Behat/ElasticsearchContext.php create mode 100644 tests/Functional/Elasticsearch/ElasticsearchSetupTrait.php rename features/elasticsearch/match_filter.feature => tests/Functional/Elasticsearch/MatchFilterTest.php (65%) rename features/elasticsearch/order_filter.feature => tests/Functional/Elasticsearch/OrderFilterTest.php (68%) rename features/elasticsearch/read.feature => tests/Functional/Elasticsearch/ReadTest.php (81%) rename features/elasticsearch/term_filter.feature => tests/Functional/Elasticsearch/TermFilterTest.php (57%) create mode 100644 tests/Functional/MongoDb/EmbedManyWithoutTargetDocumentTest.php create mode 100644 tests/Functional/MongoDb/NestedReferenceFilterErrorTest.php create mode 100644 tests/Functional/Security/ContentNegotiationErrorsTest.php create mode 100644 tests/Functional/Security/SecurityHeadersTest.php create mode 100644 tests/Functional/Security/StrongTypingTest.php create mode 100644 tests/Functional/Serializer/ConstructorDeserializationTest.php create mode 100644 tests/Functional/Serializer/DynamicGroupsTest.php create mode 100644 tests/Functional/Serializer/EmptyArrayAsObjectTest.php rename features/serializer/group_filter.feature => tests/Functional/Serializer/GroupFilterTest.php (52%) create mode 100644 tests/Functional/Serializer/GroupsRelatedTest.php rename features/serializer/property_filter.feature => tests/Functional/Serializer/PropertyFilterTest.php (50%) create mode 100644 tests/Functional/Serializer/ValueObjectRelationsTest.php create mode 100644 tools/feature_to_phpunit.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1467ffd7ba8..021767ec54b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,6 @@ jobs: php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} shard: - graphql-doctrine - - misc include: - php: '8.5' shard: graphql-doctrine @@ -494,7 +493,6 @@ jobs: run: | case "${{ matrix.shard }}" in graphql-doctrine) paths="features/graphql features/doctrine" ;; - misc) paths="features/filter features/issues features/security features/serializer features/http_cache features/sub_resources features/json features/xml features/push_relations features/mercure" ;; esac echo "paths=$paths" >> $GITHUB_OUTPUT - name: Run Behat tests (PHP ${{ matrix.php }} ${{ matrix.shard }}) @@ -824,7 +822,7 @@ jobs: continue-on-error: true elasticsearch: - name: Behat (PHP ${{ matrix.php }}) (Elasticsearch ${{ matrix.elasticsearch-version }}) + name: PHPUnit (PHP ${{ matrix.php }}) (Elasticsearch ${{ matrix.elasticsearch-version }}) runs-on: ubuntu-22.04 timeout-minutes: 20 strategy: @@ -888,11 +886,11 @@ jobs: fi - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=elasticsearch --no-interaction + - name: Run PHPUnit tests + run: vendor/bin/phpunit tests/Functional/Elasticsearch/ opensearch: - name: Behat (PHP ${{ matrix.php }}) (OpenSearch ${{ matrix.opensearch-version }}) + name: PHPUnit (PHP ${{ matrix.php }}) (OpenSearch ${{ matrix.opensearch-version }}) runs-on: ubuntu-22.04 timeout-minutes: 20 strategy: @@ -946,8 +944,8 @@ jobs: composer require --dev opensearch-project/opensearch-php "^2.5" -W - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=opensearch --no-interaction + - name: Run PHPUnit tests + run: vendor/bin/phpunit tests/Functional/Elasticsearch/ phpunit-no-deprecations: name: PHPUnit (PHP ${{ matrix.php }}) (no deprecations) diff --git a/behat.yml.dist b/behat.yml.dist index c96ba8d3a43..a771434c534 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -92,36 +92,6 @@ mercure: filters: tags: '@mercure' -elasticsearch: - suites: - default: false - elasticsearch: &elasticsearch-suite - paths: - - '%paths.base%/features/elasticsearch' - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\ElasticsearchContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '@elasticsearch&&~@mercure&&~@query_parameter_validator' - -opensearch: - suites: - default: false - opensearch: - paths: - - '%paths.base%/features/elasticsearch' - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\ElasticsearchContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '@elasticsearch&&~@mercure&&~@query_parameter_validator' - default-coverage: suites: default: &default-coverage-suite @@ -180,19 +150,6 @@ mercure-coverage: - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' -elasticsearch-coverage: - suites: - default: false - elasticsearch: &elasticsearch-coverage-suite - <<: *elasticsearch-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\ElasticsearchContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - legacy: suites: default: diff --git a/composer.json b/composer.json index 3d30deea4e3..6ae2bbb50fb 100644 --- a/composer.json +++ b/composer.json @@ -130,6 +130,8 @@ "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", + "doctrine/mongodb-odm": "^2.16", + "doctrine/mongodb-odm-bundle": "^5.6", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", "friends-of-behat/mink-browserkit-driver": "^1.3.1", @@ -146,8 +148,8 @@ "illuminate/support": "^11.0 || ^12.0 || ^13.0", "jangregor/phpstan-prophecy": "^2.1.11", "justinrainbow/json-schema": "^6.5.2", - "mcp/sdk": ">=0.4 <1.0", "laravel/framework": "^11.0 || ^12.0 || ^13.0", + "mcp/sdk": ">=0.4 <1.0", "orchestra/testbench": "^10.9 || ^11.0", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", diff --git a/features/mongodb/deserialize_embed_many_without_target_document.feature b/features/mongodb/deserialize_embed_many_without_target_document.feature deleted file mode 100644 index 5a984a96f3f..00000000000 --- a/features/mongodb/deserialize_embed_many_without_target_document.feature +++ /dev/null @@ -1,60 +0,0 @@ -@mongodb -Feature: Embed many without target document deserializable - In order to create and update resources - As a developer - I need to be able to deserialize data into objects with embed many that omit target document directive - - @createSchema - Scenario: Post a resource with embedded data - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_with_embed_many_omitting_target_documents" with body: - """ - { - "embeddedDummies": [ - { - "dummyName": "foo", - "dummyBoolean": true, - "dummyDate": "2020-01-01", - "dummyFloat": 0.1, - "dummyPrice": 10 - }, - { - "dummyName": "bar", - "dummyBoolean": false, - "dummyDate": "2021-01-01", - "dummyFloat": 0.2, - "dummyPrice": 20 - } - ] - } - """ - 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 a superset of: - """ - { - "@context": "/contexts/DummyWithEmbedManyOmittingTargetDocument", - "@id": "/dummy_with_embed_many_omitting_target_documents/1", - "@type": "DummyWithEmbedManyOmittingTargetDocument", - "id": 1, - "embeddedDummies": [ - { - "@type": "EmbeddableDummy", - "dummyName": "foo", - "dummyBoolean": true, - "dummyDate": "2020-01-01T00:00:00+00:00", - "dummyFloat": 0.1, - "dummyPrice": 10 - }, - { - "@type": "EmbeddableDummy", - "dummyName": "bar", - "dummyBoolean": false, - "dummyDate": "2021-01-01T00:00:00+00:00", - "dummyFloat": 0.2, - "dummyPrice": 20 - } - ] - } - """ diff --git a/features/mongodb/filters.feature b/features/mongodb/filters.feature deleted file mode 100644 index 5bb7c3b07e8..00000000000 --- a/features/mongodb/filters.feature +++ /dev/null @@ -1,27 +0,0 @@ -@mongodb -Feature: Filters on collections - In order to retrieve large collections of resources - As a client software developer - I need to retrieve collections with filters - - @createSchema - Scenario: Error when getting collection with nested properties if references are not correctly stored (owning side) - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.badFourthLevel.level=4" - Then the response status code should be 500 - 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 "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to "Cannot use reference 'badFourthLevel' in class 'ThirdLevel' for lookup or graphLookup: dbRef references are not supported." - And the JSON node "trace" should exist - - Scenario: Error when getting collection with nested properties if references are not correctly stored (not owning side) - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level=3" - Then the response status code should be 500 - 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 "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to "Cannot use reference 'badThirdLevel' in class 'FourthLevel' for lookup or graphLookup: dbRef references are not supported." - And the JSON node "trace" should exist diff --git a/features/security/README.md b/features/security/README.md deleted file mode 100644 index b74ad06b3a5..00000000000 --- a/features/security/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# Security tests - -This directory contains a list of tests proving that API Platform -enforces [OWASP's recommendations for REST APIs](https://www.owasp.org/index.php/REST_Security_Cheat_Sheet). -If you find a vulnerability in API Platform, please report it according to the procedure detailed in -the [CONTRIBUTING.md](../../CONTRIBUTING.md) -file. - -## Authentication and session management - -Authentication and session management is delegated to -the [Symfony Security component](http://symfony.com/doc/current/components/security.html). -This component has its own test suite. - -## Authorization - -Authorization is delegated to the [Symfony Security component](http://symfony.com/doc/current/components/security.html). -This component has its own test suite. - -## Input validation - -### Input validation 101 - -Input validation is delegated to -the [Symfony Validator component](http://symfony.com/doc/current/components/validator.html) -(an implementation of the [JSR-303 Bean Validation specification](https://jcp.org/en/jsr/detail?id=303). -This component has its own test suite. - -### Secure parsing - -Parsing is delegated to the [Symfony Serializer component](http://symfony.com/doc/current/components/serializer.html). -This component has its own test suite. - -### Strong typing - -Strong typing is ensured by [our "strong typing" functional test suite](strong_typing.feature) -and [the unit tests of the `AbstractItemNormalizer` -class](../../tests/Serializer/AbstractItemNormalizerTest.php). - -You might also be interested to see [how extra attributes are ignored](unknown_attributes.feature). - -### Validate incoming content-types - -Incoming content-types validation is ensured -by [our "validate incoming content-types" functional test suite](validate_incoming_content-types.feature) -and [the unit tests of the `DeserializeListener` -class](../../tests/EventListener/DeserializeListenerTest.php). - -### Validate response types - -Response type validation is ensured -by [our "validate response types" functional test suite](validate_response_types.feature) -and [the unit tests of the `AddFormatListener` class](../../tests/EventListener/AddFormatListenerTest.php). - -### XML input validation - -XML parsing is delegated to -the [Symfony Serializer component](http://symfony.com/doc/current/components/serializer.html). -This component has its own test suite. - -### Framework-Provided validation - -API Platform is shipped with the [Symfony Validator component](http://symfony.com/doc/current/components/validator.html) -, -one of the most popular framework validation in the world. - -## Output encoding - -### Send security headers - -The sending of security headers is ensured -by [our "send security headers" functional test suite](send_security_headers.feature) -and the unit tests of the [`RespondListener`](../../tests/EventListener/RespondListenerTest.php) -, [`ExceptionAction`](../../tests/Action/ExceptionActionTest.php) -and [`ValidationExceptionListener`](../../tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php) -. - -### JSON encoding - -API Platform relies on the [Symfony Serializer component](http://symfony.com/doc/current/components/serializer.html), to -encode JSON. -This component has its own test suite. - -### XML encoding - -API Platform relies on the [Symfony Serializer component](http://symfony.com/doc/current/components/serializer.html), to -encode XML. -This component has its own test suite. - -## Cryptography - -Cryptography for transit and storage should be enabled and properly configured on your servers depending of the nature -of -you application. -API Platform natively supports both HTTPS (always recommended) and HTTP (for read-only public data only). - -## Message Integrity - -API Platform relies on the [LexikJWTAuthenticationBundle](https://github.com/lexik/LexikJWTAuthenticationBundle), -for JWT support. -This bundle and the underlying [JSON Object Signing and Encryption library for PHP](https://github.com/namshi/jose) -library have their own test suites. - -## HTTP Return Code - -Setting proper HTTP return codes is delegated to -the [Symfony Security component](http://symfony.com/doc/current/components/security.html). -This component has its own test suite. diff --git a/features/security/send_security_headers.feature b/features/security/send_security_headers.feature deleted file mode 100644 index bca2f6fb9c1..00000000000 --- a/features/security/send_security_headers.feature +++ /dev/null @@ -1,32 +0,0 @@ -Feature: Send security header - In order to have secure API - As a client software developer - The API must send correct HTTP headers - - @createSchema - Scenario: API responses must always contain security headers - When I send a "GET" request to "/dummies" - Then the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "X-Content-Type-Options" should be equal to "nosniff" - And the header "X-Frame-Options" should be equal to "deny" - - Scenario: Exceptions responses must always contain security headers - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - {"name": 1} - """ - Then the response status code should be 400 - And the header "X-Content-Type-Options" should be equal to "nosniff" - And the header "X-Frame-Options" should be equal to "deny" - - Scenario: Error validation responses must always contain security headers - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - {"name": ""} - """ - Then the response status code should be 422 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "X-Content-Type-Options" should be equal to "nosniff" - And the header "X-Frame-Options" should be equal to "deny" diff --git a/features/security/strong_typing.feature b/features/security/strong_typing.feature deleted file mode 100644 index 27e669816dd..00000000000 --- a/features/security/strong_typing.feature +++ /dev/null @@ -1,162 +0,0 @@ -Feature: Handle properly invalid data submitted to the API - In order to have robust API - As a client software developer - The API must enforce strong typing - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Not existing", - "unsupported": true - } - """ - 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": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "Not existing", - "alias": null, - "foo": null - } - """ - - Scenario: Create a resource without a required property with a strongly-typed setter - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": null - } - """ - Then the response status code should be 400 - 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 "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'The type of the "name" attribute must be "string", "NULL" given.' - - Scenario: Create a resource with wrong value type for relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Foo", - "relatedDummy": "1" - } - """ - Then the response status code should be 400 - 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 "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'Invalid IRI "1".' - And the JSON node "trace" should exist - - Scenario: Ignore invalid dates - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Invalid date", - "dummyDate": "Invalid" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Ignore date with wrong format - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Invalid date format", - "dummyDateWithFormat": "2020-01-01T00:00:00+00:00" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Send non-array data when an array is expected - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Invalid", - "relatedDummies": "hello" - } - """ - Then the response status code should be 400 - 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 "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'The type of the "relatedDummies" attribute must be "array", "string" given.' - And the JSON node "trace" should exist - - Scenario: Send an object where an array is expected - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Invalid", - "relatedDummies": {"a": {}, "b": {}} - } - """ - Then the response status code should be 400 - 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 "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'The type of the key "a" must be "int", "string" given.' - - Scenario: Send a scalar having the bad type - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": 42 - } - """ - Then the response status code should be 400 - 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 "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'The type of the "name" attribute must be "string", "integer" given.' - - Scenario: According to the JSON spec, allow numbers without explicit floating point for JSON formats - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "foo", - "dummyFloat": 42 - } - """ - 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" diff --git a/features/security/unknown_attributes.feature b/features/security/unknown_attributes.feature deleted file mode 100644 index efe7b954c0a..00000000000 --- a/features/security/unknown_attributes.feature +++ /dev/null @@ -1,43 +0,0 @@ -Feature: Ignore unknown attributes - In order to be robust - As a client software developer - I can send unsupported attributes that will be ignored - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Not existing", - "unsupported": true - } - """ - 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": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "Not existing", - "alias": null, - "foo": null - } - """ diff --git a/features/security/validate_incoming_content-types.feature b/features/security/validate_incoming_content-types.feature deleted file mode 100644 index 80c6fd3b65a..00000000000 --- a/features/security/validate_incoming_content-types.feature +++ /dev/null @@ -1,17 +0,0 @@ -Feature: Validate incoming content type - In order to have robust API - As a client software developer - The API must check incoming the content-type - - # It's not possible to omit the Content-Type with Behat. A unit test enforce that a 406 error code is returned in such case. - - Scenario: Send a document with a not supported content-type - When I add "Content-Type" header equal to "text/plain" - And I add "Accept" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - something - """ - Then the response status code should be 415 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'The content-type "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".' diff --git a/features/security/validate_response_types.feature b/features/security/validate_response_types.feature deleted file mode 100644 index 2b0884dbb92..00000000000 --- a/features/security/validate_response_types.feature +++ /dev/null @@ -1,38 +0,0 @@ -Feature: Validate response types - In order to have robust API - As a client software developer - The API must check the requested response type - - Scenario: Send a document without content-type - When I add "Accept" header equal to "text/plain" - And I send a "GET" request to "/dummies" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Requested format "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".' - - Scenario: Requesting a different format in the Accept header and in the URL should error - When I add "Accept" header equal to "text/xml" - And I send a "GET" request to "/dummies/1.json" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Requested format "text/xml" is not supported. Supported MIME types are "application/json".' - - Scenario: Sending an invalid Accept header should error - When I add "Accept" header equal to "invalid" - And I send a "GET" request to "/dummies/1" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Requested format "invalid" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".' - - Scenario: Requesting an invalid format in the URL should throw an error - And I send a "GET" request to "/dummies/1.invalid" - Then the response status code should be 404 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Format "invalid" is not supported' - - Scenario: Requesting an invalid format in the Accept header and in the URL should throw an error - When I add "Accept" header equal to "text/invalid" - And I send a "GET" request to "/dummies/1.invalid" - Then the response status code should be 404 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Format "invalid" is not supported' diff --git a/features/serializer/deserialize_objects_using_constructor.feature b/features/serializer/deserialize_objects_using_constructor.feature deleted file mode 100644 index 24e5f20af96..00000000000 --- a/features/serializer/deserialize_objects_using_constructor.feature +++ /dev/null @@ -1,41 +0,0 @@ -Feature: Resource with constructor deserializable - In order to build non anemic resource object - As a developer - I should be able to deserialize data into objects with constructors - - @createSchema - Scenario: post a resource built with constructor - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_entity_with_constructors" with body: - """ - { - "foo": "hello", - "bar": "world", - "items": [ - { - "foo": "bar" - } - ] - } - """ - 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 a superset of: - """ - { - "@context": "/contexts/DummyEntityWithConstructor", - "@id": "/dummy_entity_with_constructors/1", - "@type": "DummyEntityWithConstructor", - "id": 1, - "foo": "hello", - "bar": "world", - "items": [ - { - "@type": "DummyObjectWithoutConstructor", - "foo": "bar" - } - ], - "baz": null - } - """ diff --git a/features/serializer/dynamic_groups.feature b/features/serializer/dynamic_groups.feature deleted file mode 100644 index 2454d0c2418..00000000000 --- a/features/serializer/dynamic_groups.feature +++ /dev/null @@ -1,11 +0,0 @@ -@!mongodb -Feature: Dynamic serialization context - In order to customize the Resource representation dynamically - As a developer - I should be able to add and remove groups - - @createSchema - Scenario: - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/relation_group_impact_on_collections/1" - And the JSON node "related.title" should be equal to "foo" diff --git a/features/serializer/empty_array_as_object.feature b/features/serializer/empty_array_as_object.feature deleted file mode 100644 index c0cc8548178..00000000000 --- a/features/serializer/empty_array_as_object.feature +++ /dev/null @@ -1,33 +0,0 @@ -Feature: Serialize empty array as object - In order to have a coherent JSON representation - As a developer - I should be able to serialize some empty array properties as objects - - @createSchema - Scenario: Get a resource with empty array properties as objects - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/empty_array_as_objects/5" - 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/EmptyArrayAsObject", - "@id": "/empty_array_as_objects/6", - "@type": "EmptyArrayAsObject", - "id": 6, - "emptyArray": [], - "emptyArrayAsObject": {}, - "arrayObjectAsArray": [], - "arrayObject": {}, - "stringArray": [ - "foo", - "bar" - ], - "objectArray": { - "foo": 67, - "bar": "baz" - } - } - """ diff --git a/features/serializer/groups_related.feature b/features/serializer/groups_related.feature deleted file mode 100644 index ad2ca49e331..00000000000 --- a/features/serializer/groups_related.feature +++ /dev/null @@ -1,17 +0,0 @@ -@!mongodb -Feature: Groups to embed relations - In order to show embed relations on a Resource - As a client software developer - I need to set up groups on the Resource embed properties - - Scenario: Get a single resource - When I send a "GET" request to "/relation_group_impact_on_collections/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "related.title" should be equal to "foo" - - Scenario: Get a collection resource not impacted by groups - When I send a "GET" request to "/relation_group_impact_on_collections" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:member[0].related" should be equal to "/relation_group_impact_on_collection_relations/1" diff --git a/features/serializer/vo_relations.feature b/features/serializer/vo_relations.feature deleted file mode 100644 index 08999440a50..00000000000 --- a/features/serializer/vo_relations.feature +++ /dev/null @@ -1,209 +0,0 @@ -Feature: Value object as ApiResource - In order to keep ApiResource immutable - As a client software developer - I need to be able to use class without setters as ApiResource - - @createSchema - Scenario: Create Value object resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/vo_dummy_cars" with body: - """ - { - "mileage": 1500, - "bodyType": "suv", - "make": "CustomCar", - "insuranceCompany": { - "name": "Safe Drive Company" - }, - "drivers": [ - { - "firstName": "John", - "lastName": "Doe" - } - ] - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoDummyCar", - "@id": "/vo_dummy_cars/1", - "@type": "VoDummyCar", - "mileage": 1500, - "bodyType": "suv", - "inspections": [], - "make": "CustomCar", - "insuranceCompany": { - "@id": "/vo_dummy_insurance_companies/1", - "@type": "VoDummyInsuranceCompany", - "name": "Safe Drive Company" - }, - "drivers": [ - { - "@id": "/vo_dummy_drivers/1", - "@type": "VoDummyDriver", - "firstName": "John", - "lastName": "Doe" - } - ] - } - """ - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - Scenario: Create Value object with IRI and nullable parameter - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/vo_dummy_inspections" with body: - """ - { - "accepted": true, - "car": "/vo_dummy_cars/1" - } - """ - Then the response status code should be 201 - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "required": ["accepted", "performed", "car"], - "properties": { - "accepted": { - "enum":[true] - }, - "performed": { - "format": "date-time" - }, - "car": { - "enum": ["/vo_dummy_cars/1"] - } - } - } - """ - - Scenario: Update Value object with writable and non writable property (legacy non-standard PUT) - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/vo_dummy_inspections/1" with body: - """ - { - "performed": "2018-08-24 00:00:00", - "accepted": false - } - """ - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoDummyInspection", - "@id": "/vo_dummy_inspections/1", - "@type": "VoDummyInspection", - "accepted": true, - "car": "/vo_dummy_cars/1", - "performed": "2018-08-24T00:00:00+00:00" - } - """ - - Scenario: Update Value object with writable and non writable property - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/vo_dummy_inspections/1" with body: - """ - { - "performed": "2018-08-24 00:00:00", - "accepted": false - } - """ - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoDummyInspection", - "@id": "/vo_dummy_inspections/1", - "@type": "VoDummyInspection", - "accepted": true, - "car": "/vo_dummy_cars/1", - "performed": "2018-08-24T00:00:00+00:00" - } - """ - - - @createSchema - Scenario: Create Value object without required params - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/vo_dummy_cars" with body: - """ - { - "mileage": 1500, - "make": "CustomCar", - "insuranceCompany": { - "name": "Safe Drive Company" - } - } - """ - Then the response status code should be 400 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^hydra:Error$" - }, - "detail": { - "pattern": "^Cannot create an instance of \"ApiPlatform\\\\Tests\\\\Fixtures\\\\TestBundle\\\\(Document|Entity)\\\\VoDummyCar\" from serialized data because its constructor requires the following parameters to be present : \"\\$drivers\".$" - } - }, - "required": [ - "@type", - "detail" - ] - } - """ - - @createSchema - Scenario: Create Value object without default param - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/vo_dummy_cars" with body: - """ - { - "mileage": 1500, - "make": "CustomCar", - "insuranceCompany": { - "name": "Safe Drive Company" - }, - "drivers": [ - { - "firstName": "John", - "lastName": "Doe" - } - ] - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoDummyCar", - "@id": "/vo_dummy_cars/1", - "@type": "VoDummyCar", - "mileage": 1500, - "bodyType": "coupe", - "inspections": [], - "make": "CustomCar", - "insuranceCompany": { - "@id": "/vo_dummy_insurance_companies/1", - "@type": "VoDummyInsuranceCompany", - "name": "Safe Drive Company" - }, - "drivers": [ - { - "@id": "/vo_dummy_drivers/1", - "@type": "VoDummyDriver", - "firstName": "John", - "lastName": "Doe" - } - ] - } - """ - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/tests/Behat/ElasticsearchContext.php b/tests/Behat/ElasticsearchContext.php deleted file mode 100644 index cc8fa176b67..00000000000 --- a/tests/Behat/ElasticsearchContext.php +++ /dev/null @@ -1,143 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use Behat\Behat\Context\Context; -use Elastic\Elasticsearch\Client; -use Elasticsearch\Client as V7Client; -use OpenSearch\Client as OpenSearchClient; -use Symfony\Component\Finder\Finder; - -/** - * @experimental - * - * @author Baptiste Meyer - */ -final class ElasticsearchContext implements Context -{ - public function __construct( - private readonly V7Client|Client|OpenSearchClient $client, // @phpstan-ignore-line - private readonly string $elasticsearchMappingsPath, - private readonly string $elasticsearchFixturesPath, - ) { - } - - /** - * @BeforeScenario - */ - public function initializeElasticsearch(): void - { - static $initialized = false; - - if ($initialized) { - return; - } - - $this->deleteIndexes(); - $this->createIndexesAndMappings(); - $this->loadFixtures(); - - $initialized = true; - } - - /** - * @Given indexes and their mappings are created - */ - public function thereAreIndexes(): void - { - $this->createIndexesAndMappings(); - } - - /** - * @Given indexes are deleted - */ - public function thereAreNoIndexes(): void - { - $this->deleteIndexes(); - } - - /** - * @Given fixtures files are loaded - */ - public function thereAreFixtures(): void - { - $this->loadFixtures(); - } - - private function createIndexesAndMappings(): void - { - $finder = new Finder(); - $finder->files()->in($this->elasticsearchMappingsPath); - - foreach ($finder as $file) { - $this->client->indices()->create([ // @phpstan-ignore-line - 'index' => $file->getBasename('.json'), - 'body' => json_decode($file->getContents(), true, 512, \JSON_THROW_ON_ERROR), - ]); - } - } - - private function deleteIndexes(): void - { - $finder = new Finder(); - $finder->files()->in($this->elasticsearchMappingsPath)->name('*.json'); - - $indexes = []; - - foreach ($finder as $file) { - $indexes[] = $file->getBasename('.json'); - } - - if ([] !== $indexes) { - $this->client->indices()->delete([ // @phpstan-ignore-line - 'index' => implode(',', $indexes), - 'ignore_unavailable' => true, - ]); - } - } - - private function loadFixtures(): void - { - $finder = new Finder(); - $finder->files()->in($this->elasticsearchFixturesPath)->name('*.json'); - - $indexClient = $this->client->indices(); // @phpstan-ignore-line - - foreach ($finder as $file) { - $index = $file->getBasename('.json'); - $bulk = []; - - foreach (json_decode($file->getContents(), true, 512, \JSON_THROW_ON_ERROR) as $document) { - if (null === ($document['id'] ?? null)) { - $bulk[] = ['index' => ['_index' => $index]]; - } else { - $bulk[] = ['create' => ['_index' => $index, '_id' => (string) $document['id']]]; - } - - $bulk[] = $document; - - if (0 === (\count($bulk) % 50)) { - $this->client->bulk(['body' => $bulk]); // @phpstan-ignore-line - $bulk = []; - } - } - - if ($bulk) { - $this->client->bulk(['body' => $bulk]); // @phpstan-ignore-line - } - - $indexClient->refresh(['index' => $index]); - } - } -} diff --git a/tests/Fixtures/app/config/config_elasticsearch.yml b/tests/Fixtures/app/config/config_elasticsearch.yml index 077f03ab209..7e796c10bd5 100644 --- a/tests/Fixtures/app/config/config_elasticsearch.yml +++ b/tests/Fixtures/app/config/config_elasticsearch.yml @@ -16,10 +16,3 @@ services: test.api_platform.elasticsearch.client: parent: api_platform.elasticsearch.client public: true - - ApiPlatform\Tests\Behat\ElasticsearchContext: - public: true - arguments: - $client: '@test.api_platform.elasticsearch.client' - $elasticsearchMappingsPath: '%kernel.project_dir%/../Elasticsearch/Mappings/' - $elasticsearchFixturesPath: '%kernel.project_dir%/../Elasticsearch/Fixtures/' diff --git a/tests/Fixtures/app/config/config_opensearch.yml b/tests/Fixtures/app/config/config_opensearch.yml index 98de050019f..1a167486ae2 100644 --- a/tests/Fixtures/app/config/config_opensearch.yml +++ b/tests/Fixtures/app/config/config_opensearch.yml @@ -17,10 +17,3 @@ services: test.api_platform.elasticsearch.client: parent: api_platform.elasticsearch.client public: true - - ApiPlatform\Tests\Behat\ElasticsearchContext: - public: true - arguments: - $client: '@test.api_platform.elasticsearch.client' - $elasticsearchMappingsPath: '%kernel.project_dir%/../Elasticsearch/Mappings/' - $elasticsearchFixturesPath: '%kernel.project_dir%/../Elasticsearch/Fixtures/' diff --git a/tests/Functional/Elasticsearch/ElasticsearchSetupTrait.php b/tests/Functional/Elasticsearch/ElasticsearchSetupTrait.php new file mode 100644 index 00000000000..c2dc995e1b4 --- /dev/null +++ b/tests/Functional/Elasticsearch/ElasticsearchSetupTrait.php @@ -0,0 +1,112 @@ + + * + * 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\Elasticsearch; + +use Symfony\Component\Finder\Finder; + +trait ElasticsearchSetupTrait +{ + private static bool $elasticsearchInitialized = false; + + protected function skipIfNotElasticsearch(): void + { + if (!\in_array($_SERVER['APP_ENV'] ?? null, ['elasticsearch', 'opensearch'], true)) { + $this->markTestSkipped('Requires APP_ENV=elasticsearch (or opensearch).'); + } + } + + protected function initializeElasticsearch(): void + { + if (self::$elasticsearchInitialized) { + return; + } + + // @phpstan-ignore-next-line service exists only when api_platform.elasticsearch.enabled is true + $client = static::getContainer()->get('test.api_platform.elasticsearch.client'); + $mappingsPath = \dirname(__DIR__, 2).'/Fixtures/Elasticsearch/Mappings/'; + $fixturesPath = \dirname(__DIR__, 2).'/Fixtures/Elasticsearch/Fixtures/'; + + $this->deleteIndexes($client, $mappingsPath); + $this->createIndexesAndMappings($client, $mappingsPath); + $this->loadFixtures($client, $fixturesPath); + + self::$elasticsearchInitialized = true; + } + + private function createIndexesAndMappings(object $client, string $mappingsPath): void + { + $finder = new Finder(); + $finder->files()->in($mappingsPath); + + foreach ($finder as $file) { + $client->indices()->create([ + 'index' => $file->getBasename('.json'), + 'body' => json_decode($file->getContents(), true, 512, \JSON_THROW_ON_ERROR), + ]); + } + } + + private function deleteIndexes(object $client, string $mappingsPath): void + { + $finder = new Finder(); + $finder->files()->in($mappingsPath)->name('*.json'); + + $indexes = []; + + foreach ($finder as $file) { + $indexes[] = $file->getBasename('.json'); + } + + if ([] !== $indexes) { + $client->indices()->delete([ + 'index' => implode(',', $indexes), + 'ignore_unavailable' => true, + ]); + } + } + + private function loadFixtures(object $client, string $fixturesPath): void + { + $finder = new Finder(); + $finder->files()->in($fixturesPath)->name('*.json'); + + $indexClient = $client->indices(); + + foreach ($finder as $file) { + $index = $file->getBasename('.json'); + $bulk = []; + + foreach (json_decode($file->getContents(), true, 512, \JSON_THROW_ON_ERROR) as $document) { + if (null === ($document['id'] ?? null)) { + $bulk[] = ['index' => ['_index' => $index]]; + } else { + $bulk[] = ['create' => ['_index' => $index, '_id' => (string) $document['id']]]; + } + + $bulk[] = $document; + + if (0 === (\count($bulk) % 50)) { + $client->bulk(['body' => $bulk]); + $bulk = []; + } + } + + if ($bulk) { + $client->bulk(['body' => $bulk]); + } + + $indexClient->refresh(['index' => $index]); + } + } +} diff --git a/features/elasticsearch/match_filter.feature b/tests/Functional/Elasticsearch/MatchFilterTest.php similarity index 65% rename from features/elasticsearch/match_filter.feature rename to tests/Functional/Elasticsearch/MatchFilterTest.php index cf0b70413a9..d33585649ac 100644 --- a/features/elasticsearch/match_filter.feature +++ b/tests/Functional/Elasticsearch/MatchFilterTest.php @@ -1,17 +1,49 @@ -@elasticsearch -Feature: Match filter on collections from Elasticsearch - In order to get specific results from a large collections of resources from Elasticsearch - As a client software developer - I need to search for resources matching the text specified - - Scenario: Match filter on a text property - When I send a "GET" request to "/tweets?message=Good%20job" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Elasticsearch; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Book; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Genre; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Library; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Tweet; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\User; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MatchFilterTest extends ApiTestCase +{ + use ElasticsearchSetupTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [User::class, Tweet::class, Library::class, Book::class, Genre::class]; + } + + public function testMatchFilterOnATextProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?message=Good%20job', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -52,16 +84,20 @@ } } } - """ - - Scenario: Match filter on a text property - When I send a "GET" request to "/tweets?message%5B%5D=Good%20job&message%5B%5D=run" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnATextPropertyWithMultipleValues(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?message%5B%5D=Good%20job&message%5B%5D=run', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -115,16 +151,20 @@ } } } - """ - - Scenario: Match filter on a nested property of text type - When I send a "GET" request to "/tweets?author.firstName=Caroline" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnANestedPropertyOfTextType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?author.firstName=Caroline', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -165,16 +205,20 @@ } } } - """ - - Scenario: Combining match filters on properties of text type and a nested property of text type - When I send a "GET" request to "/tweets?message%5B%5D=Good%20job&message%5B%5D=run&author.firstName=Caroline" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningMatchFiltersOnPropertiesOfTextTypeAndANestedPropertyOfTextType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?message%5B%5D=Good%20job&message%5B%5D=run&author.firstName=Caroline', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -215,16 +259,20 @@ } } } - """ - - Scenario: Match filter on a text property with new elasticsearch operations - When I send a "GET" request to "/books?message=Good%20job" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnATextPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?message=Good%20job', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -265,16 +313,20 @@ } } } - """ - - Scenario: Match filter on a text property with new elasticsearch operations - When I send a "GET" request to "/books?message%5B%5D=Good%20job&message%5B%5D=run" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnATextPropertyWithMultipleValuesWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?message%5B%5D=Good%20job&message%5B%5D=run', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -328,16 +380,20 @@ } } } - """ - - Scenario: Match filter on a nested property of text type with new elasticsearch operations - When I send a "GET" request to "/books?library.firstName=Caroline" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnANestedPropertyOfTextTypeWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?library.firstName=Caroline', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -378,16 +434,20 @@ } } } - """ - - Scenario: Combining match filters on properties of text type and a nested property of text type with new elasticsearch operations - When I send a "GET" request to "/books?message%5B%5D=Good%20job&message%5B%5D=run&library.firstName=Caroline" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningMatchFiltersOnPropertiesOfTextTypeAndANestedPropertyOfTextTypeWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?message%5B%5D=Good%20job&message%5B%5D=run&library.firstName=Caroline', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -428,16 +488,20 @@ } } } - """ - - Scenario: Match filter on a multi-level nested property of text type with new elasticsearch operations - When I send a "GET" request to "/books?library.relatedGenres.name=Fiction" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnAMultiLevelNestedPropertyOfTextTypeWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?library.relatedGenres.name=Fiction', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -478,5 +542,6 @@ } } } - """ - +JSON); + } +} diff --git a/features/elasticsearch/order_filter.feature b/tests/Functional/Elasticsearch/OrderFilterTest.php similarity index 68% rename from features/elasticsearch/order_filter.feature rename to tests/Functional/Elasticsearch/OrderFilterTest.php index 2fe4ac1e14b..3a1ab3d9fa4 100644 --- a/features/elasticsearch/order_filter.feature +++ b/tests/Functional/Elasticsearch/OrderFilterTest.php @@ -1,17 +1,49 @@ -@elasticsearch -Feature: Order filter on collections from Elasticsearch - In order to retrieve ordered large collections of resources from Elasticsearch - As a client software developer - I need to retrieve collections ordered properties - - Scenario: Get collection ordered in ascending order on an identifier property - When I send a "GET" request to "/tweets?order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Elasticsearch; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Book; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Genre; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Library; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Tweet; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\User; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OrderFilterTest extends ApiTestCase +{ + use ElasticsearchSetupTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array { + return [User::class, Tweet::class, Library::class, Book::class, Genre::class]; + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierProperty(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -61,16 +93,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property - When I send a "GET" request to "/tweets?order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -120,16 +156,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property and in ascending order on a nested identifier property - When I send a "GET" request to "/tweets?order%5Bauthor.id%5D=asc&order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyAndInAscendingOrderOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bauthor.id%5D=asc&order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -179,16 +219,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property and in ascending order on a nested identifier property - When I send a "GET" request to "/tweets?order%5Bauthor.id%5D=asc&order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyAndInAscendingOrderOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bauthor.id%5D=asc&order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -238,16 +282,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property and in descending order on a nested identifier property - When I send a "GET" request to "/tweets?order%5Bauthor.id%5D=desc&order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyAndInDescendingOrderOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bauthor.id%5D=desc&order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -297,16 +345,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property and in descending order on a nested identifier property - When I send a "GET" request to "/tweets?order%5Bauthor.id%5D=desc&order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyAndInDescendingOrderOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bauthor.id%5D=desc&order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -356,16 +408,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -415,16 +471,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -474,16 +534,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property and in ascending order on a nested identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Blibrary.id%5D=asc&order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyAndInAscendingOrderOnANestedIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Blibrary.id%5D=asc&order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -533,16 +597,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property and in ascending order on a nested identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Blibrary.id%5D=asc&order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyAndInAscendingOrderOnANestedIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Blibrary.id%5D=asc&order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -592,16 +660,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property and in descending order on a nested identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Blibrary.id%5D=desc&order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyAndInDescendingOrderOnANestedIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Blibrary.id%5D=desc&order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -651,16 +723,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property and in descending order on a nested identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Blibrary.id%5D=desc&order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyAndInDescendingOrderOnANestedIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Blibrary.id%5D=desc&order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -710,4 +786,6 @@ } } } - """ +JSON); + } +} diff --git a/features/elasticsearch/read.feature b/tests/Functional/Elasticsearch/ReadTest.php similarity index 81% rename from features/elasticsearch/read.feature rename to tests/Functional/Elasticsearch/ReadTest.php index 226acafaf61..79f4cdf02ce 100644 --- a/features/elasticsearch/read.feature +++ b/tests/Functional/Elasticsearch/ReadTest.php @@ -1,17 +1,49 @@ -@elasticsearch -Feature: Retrieve from Elasticsearch - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve JSON-LD encoded resources from Elasticsearch - - Scenario: Get a resource - When I send a "GET" request to "/users/116b83f8-6c32-48d8-8e28-c5c247532d3f" - 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: - """ + + * + * 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\Elasticsearch; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Book; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Genre; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Library; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Tweet; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\User; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ReadTest extends ApiTestCase +{ + use ElasticsearchSetupTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array { + return [User::class, Tweet::class, Library::class, Book::class, Genre::class]; + } + + public function testGetAResource(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users/116b83f8-6c32-48d8-8e28-c5c247532d3f', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/User", "@id": "/users/116b83f8-6c32-48d8-8e28-c5c247532d3f", "@type": "User", @@ -45,20 +77,30 @@ } ] } - """ - - Scenario: Get a not found exception - When I send a "GET" request to "/users/12345678-abcd-1234-abcdefgh" - Then the response status code should be 404 - - Scenario: Get the first page of a collection - When I send a "GET" request to "/tweets" - 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: - """ +JSON); + } + + public function testGetANotFoundException(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users/12345678-abcd-1234-abcdefgh', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetTheFirstPageOfACollection(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Tweet", "@id": "/tweets", "@type": "hydra:Collection", @@ -164,16 +206,20 @@ ] } } - """ - - Scenario: Get a page of a collection - When I send a "GET" request to "/tweets?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetAPageOfACollection(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?page=3', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Tweet", "@id": "/tweets", "@type": "hydra:Collection", @@ -280,16 +326,20 @@ ] } } - """ - - Scenario: Get the last page of a collection - When I send a "GET" request to "/tweets?page=7" - 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: - """ +JSON); + } + + public function testGetTheLastPageOfACollection(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?page=7', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Tweet", "@id": "/tweets", "@type": "hydra:Collection", @@ -379,16 +429,20 @@ ] } } - """ - - Scenario: Get a resource with new elasticsearch operations - When I send a "GET" request to "/libraries/116b83f8-6c32-48d8-8e28-c5c247532d3f" - 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: - """ +JSON); + } + + public function testGetAResourceWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries/116b83f8-6c32-48d8-8e28-c5c247532d3f', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Library", "@id": "/libraries/116b83f8-6c32-48d8-8e28-c5c247532d3f", "@type": "Library", @@ -422,20 +476,30 @@ } ] } - """ - - Scenario: Get a not found exception with new elasticsearch operations - When I send a "GET" request to "/libraries/12345678-abcd-1234-abcdefgh" - Then the response status code should be 404 - - Scenario: Get the first page of a collection with new elasticsearch operations - When I send a "GET" request to "/books" - 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: - """ +JSON); + } + + public function testGetANotFoundExceptionWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries/12345678-abcd-1234-abcdefgh', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetTheFirstPageOfACollectionWithNewElasticsearchOperations(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Book", "@id": "/books", "@type": "hydra:Collection", @@ -553,16 +617,20 @@ ] } } - """ - - Scenario: Get a page of a collection with new elasticsearch operations - When I send a "GET" request to "/books?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetAPageOfACollectionWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?page=3', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Book", "@id": "/books", "@type": "hydra:Collection", @@ -681,16 +749,20 @@ ] } } - """ - - Scenario: Get the last page of a collection with new elasticsearch operations - When I send a "GET" request to "/books?page=7" - 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: - """ +JSON); + } + + public function testGetTheLastPageOfACollectionWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?page=7', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Book", "@id": "/books", "@type": "hydra:Collection", @@ -792,4 +864,6 @@ ] } } - """ +JSON); + } +} diff --git a/features/elasticsearch/term_filter.feature b/tests/Functional/Elasticsearch/TermFilterTest.php similarity index 57% rename from features/elasticsearch/term_filter.feature rename to tests/Functional/Elasticsearch/TermFilterTest.php index 97f72fabd65..7db5145d2d9 100644 --- a/features/elasticsearch/term_filter.feature +++ b/tests/Functional/Elasticsearch/TermFilterTest.php @@ -1,17 +1,49 @@ -@elasticsearch -Feature: Term filter on collections from Elasticsearch - In order to get specific results from a large collections of resources from Elasticsearch - As a client software developer - I need to search for resources containing the exact terms specified - - Scenario: Term filter on an identifier property - When I send a "GET" request to "/users?id=%2Fusers%2Fcf875c95-41ab-48df-af66-38c74db18f72" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Elasticsearch; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Book; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Genre; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Library; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Tweet; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\User; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class TermFilterTest extends ApiTestCase +{ + use ElasticsearchSetupTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array { + return [User::class, Tweet::class, Library::class, Book::class, Genre::class]; + } + + public function testTermFilterOnAnIdentifierProperty(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?id=%2Fusers%2Fcf875c95-41ab-48df-af66-38c74db18f72', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -37,16 +69,20 @@ } } } - """ - - Scenario: Term filter on a property of keyword type - When I send a "GET" request to "/users?gender=female" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAPropertyOfKeywordType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?gender=female', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -79,16 +115,20 @@ } } } - """ - - Scenario: Combining term filters on a property of integer type and a property of keyword type - When I send a "GET" request to "/users?age=42&gender=female" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningTermFiltersOnAPropertyOfIntegerTypeAndAPropertyOfKeywordType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?age=42&gender=female', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -124,16 +164,20 @@ } } } - """ - - Scenario: Combining term filters on a property of integer type and a property of keyword type - When I send a "GET" request to "/users?age=42&gender=male" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningTermFiltersOnAPropertyOfIntegerTypeAndAPropertyOfKeywordTypeReturningNoMatch(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?age=42&gender=male', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -153,16 +197,20 @@ } } } - """ - - Scenario: Term filter on a property of text type - When I send a "GET" request to "/users?firstName=xavier" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAPropertyOfTextType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?firstName=xavier', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -189,17 +237,20 @@ } } } - """ - - Scenario: Term filter on a nested identifier property - When I send a "GET" request to "/users?tweets.id=%2Ftweets%2Fdcaef1db-225d-442b-960e-5de6984a44be" - Then the response should be in JSON - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?tweets.id=%2Ftweets%2Fdcaef1db-225d-442b-960e-5de6984a44be', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -225,17 +276,20 @@ } } } - """ - - Scenario: Term filter on a nested property of date type - When I send a "GET" request to "/users?tweets.date=2018-02-02%2014%3A14%3A14" - Then the response should be in JSON - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnANestedPropertyOfDateType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?tweets.date=2018-02-02%2014%3A14%3A14', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -261,16 +315,20 @@ } } } - """ - - Scenario: Term filter on an identifier property with elasticsearch operations - When I send a "GET" request to "/libraries?id=%2Flibraries%2Fcf875c95-41ab-48df-af66-38c74db18f72" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAnIdentifierPropertyWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?id=%2Flibraries%2Fcf875c95-41ab-48df-af66-38c74db18f72', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -296,16 +354,20 @@ } } } - """ - - Scenario: Term filter on a property of keyword type with elasticsearch operations - When I send a "GET" request to "/libraries?gender=female" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAPropertyOfKeywordTypeWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?gender=female', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -338,16 +400,20 @@ } } } - """ - - Scenario: Combining term filters on a property of integer type and a property of keyword type with elasticsearch operations - When I send a "GET" request to "/libraries?age=42&gender=female" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningTermFiltersOnAPropertyOfIntegerTypeAndAPropertyOfKeywordTypeWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?age=42&gender=female', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -383,16 +449,20 @@ } } } - """ - - Scenario: Combining term filters on a property of integer type and a property of keyword type with elasticsearch operations - When I send a "GET" request to "/libraries?age=42&gender=male" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningTermFiltersOnAPropertyOfIntegerTypeAndAPropertyOfKeywordTypeReturningNoMatchWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?age=42&gender=male', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -412,16 +482,20 @@ } } } - """ - - Scenario: Term filter on a property of text type with elasticsearch operations - When I send a "GET" request to "/libraries?firstName=xavier" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAPropertyOfTextTypeWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?firstName=xavier', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -448,17 +522,20 @@ } } } - """ - - Scenario: Term filter on a nested identifier property with elasticsearch operations - When I send a "GET" request to "/libraries?books.id=%2Fbooks%2Fdcaef1db-225d-442b-960e-5de6984a44be" - Then the response should be in JSON - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnANestedIdentifierPropertyWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?books.id=%2Fbooks%2Fdcaef1db-225d-442b-960e-5de6984a44be', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -484,17 +561,20 @@ } } } - """ - - Scenario: Term filter on a nested property of date type with elasticsearch operations - When I send a "GET" request to "/libraries?books.date=2018-02-02%2014%3A14%3A14" - Then the response should be in JSON - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnANestedPropertyOfDateTypeWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?books.date=2018-02-02%2014%3A14%3A14', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -520,4 +600,6 @@ } } } - """ +JSON); + } +} diff --git a/tests/Functional/MongoDb/EmbedManyWithoutTargetDocumentTest.php b/tests/Functional/MongoDb/EmbedManyWithoutTargetDocumentTest.php new file mode 100644 index 00000000000..6d4546c5148 --- /dev/null +++ b/tests/Functional/MongoDb/EmbedManyWithoutTargetDocumentTest.php @@ -0,0 +1,70 @@ + + * + * 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\MongoDb; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyWithEmbedManyOmittingTargetDocument; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EmbedManyWithoutTargetDocumentTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [DummyWithEmbedManyOmittingTargetDocument::class]; + } + + protected function setUp(): void + { + if (!$this->isMongoDB()) { + $this->markTestSkipped('Requires APP_ENV=mongodb.'); + } + $this->recreateSchema([DummyWithEmbedManyOmittingTargetDocument::class]); + } + + public function testPostHydratesEmbedManyWithoutTargetDocument(): void + { + self::createClient()->request( + 'POST', + '/dummy_with_embed_many_omitting_target_documents', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'embeddedDummies' => [ + ['dummyName' => 'foo', 'dummyBoolean' => true, 'dummyDate' => '2020-01-01', 'dummyFloat' => 0.1, 'dummyPrice' => 10], + ['dummyName' => 'bar', 'dummyBoolean' => false, 'dummyDate' => '2021-01-01', 'dummyFloat' => 0.2, 'dummyPrice' => 20], + ], + ]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/DummyWithEmbedManyOmittingTargetDocument', + '@id' => '/dummy_with_embed_many_omitting_target_documents/1', + '@type' => 'DummyWithEmbedManyOmittingTargetDocument', + 'id' => 1, + 'embeddedDummies' => [ + ['@type' => 'EmbeddableDummy', 'dummyName' => 'foo', 'dummyBoolean' => true, 'dummyDate' => '2020-01-01T00:00:00+00:00', 'dummyFloat' => 0.1, 'dummyPrice' => 10], + ['@type' => 'EmbeddableDummy', 'dummyName' => 'bar', 'dummyBoolean' => false, 'dummyDate' => '2021-01-01T00:00:00+00:00', 'dummyFloat' => 0.2, 'dummyPrice' => 20], + ], + ]); + } +} diff --git a/tests/Functional/MongoDb/NestedReferenceFilterErrorTest.php b/tests/Functional/MongoDb/NestedReferenceFilterErrorTest.php new file mode 100644 index 00000000000..52275103792 --- /dev/null +++ b/tests/Functional/MongoDb/NestedReferenceFilterErrorTest.php @@ -0,0 +1,99 @@ + + * + * 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\MongoDb; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NestedReferenceFilterErrorTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]; + } + + protected function setUp(): void + { + if (!$this->isMongoDB()) { + $this->markTestSkipped('Requires APP_ENV=mongodb.'); + } + $this->recreateSchema([Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]); + + $manager = $this->getManager(); + + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + $manager->clear(); + } + + public function testOwningSideBadReferenceTriggers500(): void + { + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.badFourthLevel.level=4', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(500); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('/contexts/Error', $body['@context']); + $this->assertSame('hydra:Error', $body['@type']); + $this->assertSame("Cannot use reference 'badFourthLevel' in class 'ThirdLevel' for lookup or graphLookup: dbRef references are not supported.", $body['detail']); + $this->assertArrayHasKey('trace', $body); + } + + public function testNonOwningSideBadReferenceTriggers500(): void + { + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level=3', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(500); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('/contexts/Error', $body['@context']); + $this->assertSame('hydra:Error', $body['@type']); + $this->assertSame("Cannot use reference 'badThirdLevel' in class 'FourthLevel' for lookup or graphLookup: dbRef references are not supported.", $body['detail']); + $this->assertArrayHasKey('trace', $body); + } +} diff --git a/tests/Functional/Security/ContentNegotiationErrorsTest.php b/tests/Functional/Security/ContentNegotiationErrorsTest.php new file mode 100644 index 00000000000..e02bfa88145 --- /dev/null +++ b/tests/Functional/Security/ContentNegotiationErrorsTest.php @@ -0,0 +1,110 @@ + + * + * 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\Security; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ContentNegotiationErrorsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + public function testUnsupportedRequestContentTypeReturns415(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'text/plain', 'Accept' => 'application/ld+json'], + 'body' => 'something', + ], + ); + + $this->assertResponseStatusCodeSame(415); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'The content-type "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".', + ]); + } + + public function testUnsupportedAcceptHeaderReturns406(): void + { + self::createClient()->request('GET', '/dummies', ['headers' => ['Accept' => 'text/plain']]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Requested format "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".', + ]); + } + + public function testAcceptHeaderDifferentFromUrlFormatReturns406(): void + { + self::createClient()->request('GET', '/dummies/1.json', ['headers' => ['Accept' => 'text/xml']]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Requested format "text/xml" is not supported. Supported MIME types are "application/json".', + ]); + } + + public function testInvalidAcceptHeaderReturns406(): void + { + self::createClient()->request('GET', '/dummies/1', ['headers' => ['Accept' => 'invalid']]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Requested format "invalid" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".', + ]); + } + + public function testInvalidUrlFormatReturns404(): void + { + self::createClient()->request('GET', '/dummies/1.invalid'); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Format "invalid" is not supported', + ]); + } + + public function testInvalidUrlFormatAndAcceptReturns404(): void + { + self::createClient()->request('GET', '/dummies/1.invalid', ['headers' => ['Accept' => 'text/invalid']]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Format "invalid" is not supported', + ]); + } +} diff --git a/tests/Functional/Security/SecurityHeadersTest.php b/tests/Functional/Security/SecurityHeadersTest.php new file mode 100644 index 00000000000..cacbf430537 --- /dev/null +++ b/tests/Functional/Security/SecurityHeadersTest.php @@ -0,0 +1,79 @@ + + * + * 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\Security; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SecurityHeadersTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + public function testCollectionResponseIncludesSecurityHeaders(): void + { + self::createClient()->request('GET', '/dummies', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); + $this->assertResponseHeaderSame('x-frame-options', 'deny'); + } + + public function testDeserializationErrorResponseIncludesSecurityHeaders(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'body' => '{"name": 1}', + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); + $this->assertResponseHeaderSame('x-frame-options', 'deny'); + } + + public function testValidationErrorResponseIncludesSecurityHeaders(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'body' => '{"name": ""}', + ], + ); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); + $this->assertResponseHeaderSame('x-frame-options', 'deny'); + } +} diff --git a/tests/Functional/Security/StrongTypingTest.php b/tests/Functional/Security/StrongTypingTest.php new file mode 100644 index 00000000000..d42b6a8aab9 --- /dev/null +++ b/tests/Functional/Security/StrongTypingTest.php @@ -0,0 +1,224 @@ + + * + * 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\Security; + +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\RelatedOwnedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class StrongTypingTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class, RelatedDummy::class, RelatedOwnedDummy::class, RelatedOwningDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + public function testIgnoreUnsupportedAttributes(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Not existing', 'unsupported' => true]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'description' => null, + 'dummy' => null, + 'dummyBoolean' => null, + 'dummyDate' => null, + 'dummyFloat' => null, + 'dummyPrice' => null, + 'relatedDummy' => null, + 'relatedDummies' => [], + 'jsonData' => [], + 'arrayData' => [], + 'name_converted' => null, + 'relatedOwnedDummy' => null, + 'relatedOwningDummy' => null, + 'id' => 1, + 'name' => 'Not existing', + 'alias' => null, + 'foo' => null, + ]); + } + + public function testNullValueForRequiredStringTriggersTypeError(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => null]), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'The type of the "name" attribute must be "string", "NULL" given.', + ]); + } + + public function testStringInsteadOfIriOnRelationTriggersInvalidIri(): void + { + $response = self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Foo', 'relatedDummy' => '1']), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'Invalid IRI "1".', + ]); + $this->assertArrayHasKey('trace', $response->toArray(false)); + } + + public function testInvalidDateStringIsRejected(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Invalid date', 'dummyDate' => 'Invalid']), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + } + + public function testDateWithUnexpectedFormatIsRejected(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Invalid date format', 'dummyDateWithFormat' => '2020-01-01T00:00:00+00:00']), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + } + + public function testStringInsteadOfArrayOnCollectionRelationTriggersTypeError(): void + { + $response = self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Invalid', 'relatedDummies' => 'hello']), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'The type of the "relatedDummies" attribute must be "array", "string" given.', + ]); + $this->assertArrayHasKey('trace', $response->toArray(false)); + } + + public function testAssociativeObjectInsteadOfListOnCollectionTriggersKeyTypeError(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Invalid', 'relatedDummies' => ['a' => new \stdClass(), 'b' => new \stdClass()]]), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'The type of the key "a" must be "int", "string" given.', + ]); + } + + public function testIntegerInsteadOfStringScalarTriggersTypeError(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 42]), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'The type of the "name" attribute must be "string", "integer" given.', + ]); + } + + public function testIntegerIsAcceptedForFloatProperty(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'foo', 'dummyFloat' => 42]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + } +} diff --git a/tests/Functional/Serializer/ConstructorDeserializationTest.php b/tests/Functional/Serializer/ConstructorDeserializationTest.php new file mode 100644 index 00000000000..d8439a9cfe5 --- /dev/null +++ b/tests/Functional/Serializer/ConstructorDeserializationTest.php @@ -0,0 +1,65 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyEntityWithConstructor; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ConstructorDeserializationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [DummyEntityWithConstructor::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; `Entity` → `Document` rewrite mangles "DummyEntityWithConstructor".'); + } + $this->recreateSchema([DummyEntityWithConstructor::class]); + } + + public function testPostHydratesObjectViaConstructor(): void + { + self::createClient()->request( + 'POST', + '/dummy_entity_with_constructors', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['foo' => 'hello', 'bar' => 'world', 'items' => [['foo' => 'bar']]]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/DummyEntityWithConstructor', + '@id' => '/dummy_entity_with_constructors/1', + '@type' => 'DummyEntityWithConstructor', + 'id' => 1, + 'foo' => 'hello', + 'bar' => 'world', + 'items' => [['@type' => 'DummyObjectWithoutConstructor', 'foo' => 'bar']], + 'baz' => null, + ]); + } +} diff --git a/tests/Functional/Serializer/DynamicGroupsTest.php b/tests/Functional/Serializer/DynamicGroupsTest.php new file mode 100644 index 00000000000..70bb7de98bd --- /dev/null +++ b/tests/Functional/Serializer/DynamicGroupsTest.php @@ -0,0 +1,49 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationGroupImpactOnCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationGroupImpactOnCollectionRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class DynamicGroupsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationGroupImpactOnCollection::class, RelationGroupImpactOnCollectionRelation::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + } + + public function testDynamicGroupContextIncludesNestedField(): void + { + $response = self::createClient()->request('GET', '/relation_group_impact_on_collections/1', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('foo', $body['related']['title']); + } +} diff --git a/tests/Functional/Serializer/EmptyArrayAsObjectTest.php b/tests/Functional/Serializer/EmptyArrayAsObjectTest.php new file mode 100644 index 00000000000..876a7237056 --- /dev/null +++ b/tests/Functional/Serializer/EmptyArrayAsObjectTest.php @@ -0,0 +1,52 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\EmptyArrayAsObject; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EmptyArrayAsObjectTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [EmptyArrayAsObject::class]; + } + + public function testGetResourcePreservesEmptyArrayAsObject(): void + { + self::createClient()->request('GET', '/empty_array_as_objects/5', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/EmptyArrayAsObject", + "@id": "/empty_array_as_objects/6", + "@type": "EmptyArrayAsObject", + "id": 6, + "emptyArray": [], + "emptyArrayAsObject": {}, + "arrayObjectAsArray": [], + "arrayObject": {}, + "stringArray": ["foo", "bar"], + "objectArray": {"foo": 67, "bar": "baz"} +} +JSON); + } +} diff --git a/features/serializer/group_filter.feature b/tests/Functional/Serializer/GroupFilterTest.php similarity index 52% rename from features/serializer/group_filter.feature rename to tests/Functional/Serializer/GroupFilterTest.php index 95de871d408..6540516b346 100644 --- a/features/serializer/group_filter.feature +++ b/tests/Functional/Serializer/GroupFilterTest.php @@ -1,18 +1,84 @@ -Feature: Filter with serialization groups on items and collections - In order to retrieve, create and update resources or large collections of resources - As a client software developer - I need to retrieve, create and update resources or collections of resources with serialization groups - - @createSchema - Scenario: Get a collection of resources by group dummy_foo without overriding - Given there are 10 dummy group objects - When I send a "GET" request to "/dummy_groups?groups[]=dummy_foo" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ORM\EntityManagerInterface; + +final class GroupFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private static bool $fixturesLoaded = false; + + public static function getResources(): array { + return [DummyGroup::class]; + } + + public static function tearDownAfterClass(): void + { + self::$fixturesLoaded = false; + parent::tearDownAfterClass(); + } + + protected function loadFixtures(): void + { + if (self::$fixturesLoaded) { + return; + } + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; direct EntityManager persist of Entity\\DummyGroup is not portable to DocumentManager.'); + } + self::createClient(); + $this->recreateSchema([DummyGroup::class]); + + /** @var EntityManagerInterface $manager */ + $manager = $this->getManager(); + + for ($i = 1; $i <= 10; ++$i) { + $group = new DummyGroup(); + foreach (['foo', 'bar', 'baz', 'qux'] as $field) { + $group->{$field} = ucfirst($field).' #'.$i; + } + $manager->persist($group); + } + $manager->flush(); + $manager->clear(); + self::$fixturesLoaded = true; + } + + public function testGetACollectionOfResourcesByGroupDummyFooWithoutOverriding(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -74,16 +140,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by group dummy_foo with overriding - When I send a "GET" request to "/dummy_groups?override_groups[]=dummy_foo" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupDummyFooWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?override_groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -136,16 +209,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by groups dummy_foo, dummy_qux and without overriding - When I send a "GET" request to "/dummy_groups?groups[]=dummy_foo&groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupsDummyFooDummyQuxAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?groups[]=dummy_foo&groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -210,16 +290,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by groups dummy_foo, dummy_qux and with overriding - When I send a "GET" request to "/dummy_groups?override_groups[]=dummy_foo&override_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupsDummyFooDummyQuxAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?override_groups[]=dummy_foo&override_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -275,17 +362,23 @@ } } } - """ - +JSON); + } - Scenario: Get a collection of resources by groups dummy_foo, dummy_qux, without overriding and with whitelist - When I send a "GET" request to "/dummy_groups?whitelisted_groups[]=dummy_foo&whitelisted_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupsDummyFooDummyQuxWithoutOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?whitelisted_groups[]=dummy_foo&whitelisted_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -347,16 +440,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by groups dummy_foo, dummy_qux with overriding and with whitelist - When I send a "GET" request to "/dummy_groups?override_whitelisted_groups[]=dummy_foo&override_whitelisted_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupsDummyFooDummyQuxWithOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?override_whitelisted_groups[]=dummy_foo&override_whitelisted_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -409,16 +509,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by group empty and without overriding - When I send a "GET" request to "/dummy_groups?groups[]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupEmptyAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -480,16 +587,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by group empty and with overriding - When I send a "GET" request to "/dummy_groups?override_groups[]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupEmptyAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?override_groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -539,16 +653,23 @@ } } } - """ +JSON); + } - Scenario: Get a resource by group dummy_foo without overriding - When I send a "GET" request to "/dummy_groups/1?groups[]=dummy_foo" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupDummyFooWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -562,16 +683,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar", "baz"] } - """ +JSON); + } - Scenario: Get a resource by group dummy_foo with overriding - When I send a "GET" request to "/dummy_groups/1?override_groups[]=dummy_foo" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupDummyFooWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?override_groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -582,16 +710,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "foo"] } - """ +JSON); + } - Scenario: Get a resource by groups dummy_foo, dummy_qux and without overriding - When I send a "GET" request to "/dummy_groups/1?groups[]=dummy_foo&groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupsDummyFooDummyQuxAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?groups[]=dummy_foo&groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -606,16 +741,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar", "baz", "qux"] } - """ +JSON); + } - Scenario: Get a resource by groups dummy_foo, dummy_qux and with overriding - When I send a "GET" request to "/dummy_groups/1?override_groups[]=dummy_foo&override_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupsDummyFooDummyQuxAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?override_groups[]=dummy_foo&override_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -627,16 +769,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "foo", "qux"] } - """ +JSON); + } - Scenario: Get a resource by groups dummy_foo, dummy_qux and without overriding and with whitelist - When I send a "GET" request to "/dummy_groups/1?whitelisted_groups[]=dummy_foo&whitelisted_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupsDummyFooDummyQuxAndWithoutOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?whitelisted_groups[]=dummy_foo&whitelisted_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -650,16 +799,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar", "baz"] } - """ +JSON); + } - Scenario: Get a resource by groups dummy_foo, dummy_qux and with overriding and with whitelist - When I send a "GET" request to "/dummy_groups/1?override_whitelisted_groups[]=dummy_foo&override_whitelisted_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupsDummyFooDummyQuxAndWithOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?override_whitelisted_groups[]=dummy_foo&override_whitelisted_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -670,16 +826,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "foo"] } - """ +JSON); + } - Scenario: Get a resource by group empty and without overriding - When I send a "GET" request to "/dummy_groups/1?groups[]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupEmptyAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -693,16 +856,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar", "baz"] } - """ +JSON); + } - Scenario: Get a resource by group empty and with overriding - When I send a "GET" request to "/dummy_groups/1?override_groups[]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupEmptyAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?override_groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -712,25 +882,30 @@ "additionalProperties": false, "required": ["@context", "@id", "@type"] } - """ +JSON); + } - Scenario: Create a resource by group dummy_foo and without overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?groups[]=dummy_foo" with body: - """ + public function testCreateAResourceByGroupDummyFooAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/11", "@type": "DummyGroup", @@ -739,49 +914,59 @@ "bar": "Bar", "baz": null } - """ +JSON); + } - Scenario: Create a resource by group dummy_foo and with overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_groups[]=dummy_foo" with body: - """ + public function testCreateAResourceByGroupDummyFooAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/12", "@type": "DummyGroup", "foo": "Foo" } - """ +JSON); + } - Scenario: Create a resource by groups dummy_foo, dummy_baz, dummy_qux and without overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?groups[]=dummy_foo&groups[]=dummy_baz&groups[]=dummy_qux" with body: - """ + public function testCreateAResourceByGroupsDummyFooDummyBazDummyQuxAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?groups[]=dummy_foo&groups[]=dummy_baz&groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/13", "@type": "DummyGroup", @@ -791,25 +976,30 @@ "baz": "Baz", "qux": "Qux" } - """ +JSON); + } - Scenario: Create a resource by groups dummy_foo, dummy_baz, dummy_qux and with overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_groups[]=dummy_foo&override_groups[]=dummy_baz&override_groups[]=dummy_qux" with body: - """ + public function testCreateAResourceByGroupsDummyFooDummyBazDummyQuxAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_groups[]=dummy_foo&override_groups[]=dummy_baz&override_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/14", "@type": "DummyGroup", @@ -817,25 +1007,30 @@ "baz": "Baz", "qux": "Qux" } - """ +JSON); + } - Scenario: Create a resource by groups dummy, dummy_baz, without overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?groups[]=dummy&groups[]=dummy_baz" with body: - """ + public function testCreateAResourceByGroupsDummyDummyBazWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?groups[]=dummy&groups[]=dummy_baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/15", "@type": "DummyGroup", @@ -845,25 +1040,30 @@ "baz": "Baz", "qux": "Qux" } - """ +JSON); + } - Scenario: Create a resource by groups dummy, dummy_baz and with overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_groups[]=dummy&override_groups[]=dummy_baz" with body: - """ + public function testCreateAResourceByGroupsDummyDummyBazAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_groups[]=dummy&override_groups[]=dummy_baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/16", "@type": "DummyGroup", @@ -873,25 +1073,30 @@ "baz": "Baz", "qux": "Qux" } - """ +JSON); + } - Scenario: Create a resource by group empty and without overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?groups[]=" with body: - """ + public function testCreateAResourceByGroupEmptyAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/17", "@type": "DummyGroup", @@ -900,48 +1105,58 @@ "bar": "Bar", "baz": null } - """ +JSON); + } - Scenario: Create a resource by group empty and with overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_groups[]=" with body: - """ + public function testCreateAResourceByGroupEmptyAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/18", "@type": "DummyGroup" } - """ +JSON); + } - Scenario: Create a resource by groups dummy, dummy_baz, without overriding and with whitelist - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?whitelisted_groups[]=dummy&whitelisted_groups[]=dummy_baz" with body: - """ + public function testCreateAResourceByGroupsDummyDummyBazWithoutOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?whitelisted_groups[]=dummy&whitelisted_groups[]=dummy_baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/19", "@type": "DummyGroup", @@ -950,28 +1165,35 @@ "bar": "Bar", "baz": "Baz" } - """ +JSON); + } - Scenario: Create a resource by groups dummy, dummy_baz, with overriding and with whitelist - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_whitelisted_groups[]=dummy&override_whitelisted_groups[]=dummy_baz" with body: - """ + public function testCreateAResourceByGroupsDummyDummyBazWithOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_whitelisted_groups[]=dummy&override_whitelisted_groups[]=dummy_baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/20", "@type": "DummyGroup", "baz": "Baz" } - """ +JSON); + } +} diff --git a/tests/Functional/Serializer/GroupsRelatedTest.php b/tests/Functional/Serializer/GroupsRelatedTest.php new file mode 100644 index 00000000000..bd178b68c05 --- /dev/null +++ b/tests/Functional/Serializer/GroupsRelatedTest.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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationGroupImpactOnCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationGroupImpactOnCollectionRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class GroupsRelatedTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationGroupImpactOnCollection::class, RelationGroupImpactOnCollectionRelation::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; no Document version of RelationGroupImpactOnCollection.'); + } + } + + public function testItemExposesGroupedNestedProperty(): void + { + $response = self::createClient()->request('GET', '/relation_group_impact_on_collections/1', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('foo', $body['related']['title']); + } + + public function testCollectionInlinesRelationAsIri(): void + { + $response = self::createClient()->request('GET', '/relation_group_impact_on_collections', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('/relation_group_impact_on_collection_relations/1', $body['hydra:member'][0]['related']); + } + + public function testDynamicGroupsViaCustomNormalizerAddsGroupedField(): void + { + $response = self::createClient()->request('GET', '/custom_normalizer_relation_group_impact_on_collection', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('foo', $body['related']['title']); + } +} diff --git a/features/serializer/property_filter.feature b/tests/Functional/Serializer/PropertyFilterTest.php similarity index 50% rename from features/serializer/property_filter.feature rename to tests/Functional/Serializer/PropertyFilterTest.php index 7da85deb692..4c152293362 100644 --- a/features/serializer/property_filter.feature +++ b/tests/Functional/Serializer/PropertyFilterTest.php @@ -1,18 +1,91 @@ -Feature: Filter with serialization attributes on items and collections - In order to retrieve, create and update resources or large collection of resources - As a client software developer - I need to retrieve, create and update resources or collections of resources with serialization attributes - - @createSchema - Scenario: Get a collection of resources by attributes id, foo and bar - Given there are 10 dummy property objects - When I send a "GET" request to "/dummy_properties?properties[]=id&properties[]=foo&properties[]=bar&properties[]=name_converted" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ORM\EntityManagerInterface; + +final class PropertyFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [DummyProperty::class, DummyGroup::class]; + } + + private static bool $fixturesLoaded = false; + + protected function loadFixtures(): void { + if (self::$fixturesLoaded) { + return; + } + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; direct EntityManager persist of Entity\\DummyGroup is not portable to DocumentManager.'); + } + self::createClient(); + $this->recreateSchema([DummyProperty::class, DummyGroup::class]); + + /** @var EntityManagerInterface $manager */ + $manager = $this->getManager(); + + for ($i = 1; $i <= 10; ++$i) { + $group = new DummyGroup(); + $property = new DummyProperty(); + + foreach (['foo', 'bar', 'baz'] as $field) { + $property->{$field} = $group->{$field} = ucfirst($field).' #'.$i; + } + $property->nameConverted = "NameConverted #{$i}"; + $property->group = $group; + + $manager->persist($group); + $manager->persist($property); + } + $manager->flush(); + $manager->clear(); + self::$fixturesLoaded = true; + } + + public static function tearDownAfterClass(): void + { + self::$fixturesLoaded = false; + parent::tearDownAfterClass(); + } + + public function testGetACollectionOfResourcesByAttributesIdFooAndBar(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?properties[]=id&properties[]=foo&properties[]=bar&properties[]=name_converted', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -46,16 +119,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes foo, bar, group.baz and group.qux - When I send a "GET" request to "/dummy_properties?properties[]=foo&properties[]=bar&properties[group][]=baz&properties[group][]=qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByAttributesFooBarGroupBazAndGroupQux(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?properties[]=foo&properties[]=bar&properties[group][]=baz&properties[group][]=qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -96,16 +176,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes foo, bar - When I send a "GET" request to "/dummy_properties?whitelisted_properties[]=foo&whitelisted_properties[]=bar&whitelisted_properties[]=name_converted" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByAttributesFooBar(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?whitelisted_properties[]=foo&whitelisted_properties[]=bar&whitelisted_properties[]=name_converted', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -136,16 +223,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes foo, bar, group.baz and group.qux - When I send a "GET" request to "/dummy_properties?whitelisted_nested_properties[]=foo&whitelisted_nested_properties[]=bar&whitelisted_nested_properties[group][]=baz" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByWhitelistedNestedPropertiesFooBarAndGroupBaz(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?whitelisted_nested_properties[]=foo&whitelisted_nested_properties[]=bar&whitelisted_nested_properties[group][]=baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -185,16 +279,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes bar not allowed - When I send a "GET" request to "/dummy_properties?whitelisted_properties[]=bar" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByAttributesBarNotAllowed(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?whitelisted_properties[]=bar', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -223,16 +324,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes empty - When I send a "GET" request to "/dummy_properties?properties[]=&properties[group][]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByAttributesEmpty(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?properties[]=&properties[group][]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -270,16 +378,23 @@ } } } - """ - - Scenario: Get a resource by attributes id, foo and bar - When I send a "GET" request to "/dummy_properties/1?properties[]=id&properties[]=foo&properties[]=bar" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetAResourceByAttributesIdFooAndBar(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties/1?properties[]=id&properties[]=foo&properties[]=bar', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -292,16 +407,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar"] } - """ - - Scenario: Get a resource by attributes foo, bar, group.baz and group.qux - When I send a "GET" request to "/dummy_properties/1?properties[]=foo&properties[]=bar&properties[group][]=baz&properties[group][]=qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetAResourceByAttributesFooBarGroupBazAndGroupQux(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties/1?properties[]=foo&properties[]=bar&properties[group][]=baz&properties[group][]=qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -323,16 +445,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "foo", "bar", "group"] } - """ - - Scenario: Get a resource by attributes empty - When I send a "GET" request to "/dummy_properties/1?properties[]=&properties[group][]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetAResourceByAttributesEmpty(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties/1?properties[]=&properties[group][]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -351,36 +480,47 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "group"] } - """ +JSON); + } - Scenario: Create a resource by attributes foo and bar - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_properties?properties[]=foo&properties[]=bar" with body: - """ + public function testCreateAResourceByAttributesFooAndBar(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_properties?properties[]=foo&properties[]=bar', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar" - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyProperty", "@id": "/dummy_properties/11", "@type": "DummyProperty", "foo": "Foo", "bar": "Bar" } - """ +JSON); + } - Scenario: Create a resource by attributes foo, bar, group.foo, group.baz and group.qux - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_properties?properties[]=foo&properties[]=bar&properties[group][]=foo&properties[group][]=baz&properties[group][]=qux" with body: - """ + public function testCreateAResourceByAttributesFooBarGroupFooGroupBazAndGroupQux(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_properties?properties[]=foo&properties[]=bar&properties[group][]=foo&properties[group][]=baz&properties[group][]=qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "group": { @@ -388,14 +528,13 @@ "baz": "Baz", "qux": "Qux" } - } - """ - 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: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyProperty", "@id": "/dummy_properties/12", "@type": "DummyProperty", @@ -408,4 +547,6 @@ "baz": null } } - """ +JSON); + } +} diff --git a/tests/Functional/Serializer/ValueObjectRelationsTest.php b/tests/Functional/Serializer/ValueObjectRelationsTest.php new file mode 100644 index 00000000000..00d4bfa6af2 --- /dev/null +++ b/tests/Functional/Serializer/ValueObjectRelationsTest.php @@ -0,0 +1,270 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyDriver; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyInspection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyInsuranceCompany; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyVehicle; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ValueObjectRelationsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [VoDummyCar::class, VoDummyVehicle::class, VoDummyDriver::class, VoDummyInspection::class, VoDummyInsuranceCompany::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; VoDummy hierarchy uses Doctrine ORM-specific cascading expectations.'); + } + $this->recreateSchema(static::getResources()); + } + + public function testPostHydratesValueObjectViaConstructor(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_cars', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'mileage' => 1500, + 'bodyType' => 'suv', + 'make' => 'CustomCar', + 'insuranceCompany' => ['name' => 'Safe Drive Company'], + 'drivers' => [['firstName' => 'John', 'lastName' => 'Doe']], + ]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/VoDummyCar", + "@id": "/vo_dummy_cars/1", + "@type": "VoDummyCar", + "mileage": 1500, + "bodyType": "suv", + "inspections": [], + "make": "CustomCar", + "insuranceCompany": { + "@id": "/vo_dummy_insurance_companies/1", + "@type": "VoDummyInsuranceCompany", + "name": "Safe Drive Company" + }, + "drivers": [{ + "@id": "/vo_dummy_drivers/1", + "@type": "VoDummyDriver", + "firstName": "John", + "lastName": "Doe" + }] +} +JSON); + } + + public function testPostInspectionWithIriRelation(): void + { + $this->createCar(); + + self::createClient()->request( + 'POST', + '/vo_dummy_inspections', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['accepted' => true, 'car' => '/vo_dummy_cars/1']), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertMatchesJsonSchema(<<<'JSON' +{ + "type": "object", + "required": ["accepted", "performed", "car"], + "properties": { + "accepted": {"enum": [true]}, + "performed": {"format": "date-time"}, + "car": {"enum": ["/vo_dummy_cars/1"]} + } +} +JSON); + } + + public function testLegacyPutKeepsImmutableProperties(): void + { + $this->createCar(); + $this->createInspection(); + + self::createClient()->request( + 'PUT', + '/vo_dummy_inspections/1', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['performed' => '2018-08-24 00:00:00', 'accepted' => false]), + ], + ); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/VoDummyInspection", + "@id": "/vo_dummy_inspections/1", + "@type": "VoDummyInspection", + "accepted": true, + "car": "/vo_dummy_cars/1", + "performed": "2018-08-24T00:00:00+00:00" +} +JSON); + } + + public function testPatchKeepsImmutableProperties(): void + { + $this->createCar(); + $this->createInspection(); + + self::createClient()->request( + 'PATCH', + '/vo_dummy_inspections/1', + [ + 'headers' => ['Content-Type' => 'application/merge-patch+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['performed' => '2018-08-24 00:00:00', 'accepted' => false]), + ], + ); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/VoDummyInspection", + "@id": "/vo_dummy_inspections/1", + "@type": "VoDummyInspection", + "accepted": true, + "car": "/vo_dummy_cars/1", + "performed": "2018-08-24T00:00:00+00:00" +} +JSON); + } + + public function testMissingRequiredConstructorParameterReturnsError(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_cars', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'mileage' => 1500, + 'make' => 'CustomCar', + 'insuranceCompany' => ['name' => 'Safe Drive Company'], + ]), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertStringContainsString('; rel="http://www.w3.org/ns/json-ld#error"', self::getClient()->getResponse()->headers->get('link') ?? ''); + $this->assertMatchesJsonSchema(<<<'JSON' +{ + "type": "object", + "required": ["@type", "detail"], + "properties": { + "@type": {"type": "string", "pattern": "^hydra:Error$"}, + "detail": {"pattern": "^Cannot create an instance of \"ApiPlatform\\\\Tests\\\\Fixtures\\\\TestBundle\\\\(Document|Entity)\\\\VoDummyCar\" from serialized data because its constructor requires the following parameters to be present : \"\\$drivers\".$"} + } +} +JSON); + } + + public function testDefaultConstructorParameterIsApplied(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_cars', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'mileage' => 1500, + 'make' => 'CustomCar', + 'insuranceCompany' => ['name' => 'Safe Drive Company'], + 'drivers' => [['firstName' => 'John', 'lastName' => 'Doe']], + ]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/VoDummyCar", + "@id": "/vo_dummy_cars/1", + "@type": "VoDummyCar", + "mileage": 1500, + "bodyType": "coupe", + "inspections": [], + "make": "CustomCar", + "insuranceCompany": { + "@id": "/vo_dummy_insurance_companies/1", + "@type": "VoDummyInsuranceCompany", + "name": "Safe Drive Company" + }, + "drivers": [{ + "@id": "/vo_dummy_drivers/1", + "@type": "VoDummyDriver", + "firstName": "John", + "lastName": "Doe" + }] +} +JSON); + } + + private function createCar(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_cars', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'mileage' => 1500, + 'bodyType' => 'suv', + 'make' => 'CustomCar', + 'insuranceCompany' => ['name' => 'Safe Drive Company'], + 'drivers' => [['firstName' => 'John', 'lastName' => 'Doe']], + ]), + ], + ); + } + + private function createInspection(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_inspections', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['accepted' => true, 'car' => '/vo_dummy_cars/1']), + ], + ); + } +} diff --git a/tools/feature_to_phpunit.php b/tools/feature_to_phpunit.php new file mode 100644 index 00000000000..16bcc82b9eb --- /dev/null +++ b/tools/feature_to_phpunit.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); + +$args = array_slice($argv, 1); +$setupHook = ''; + +if ($args && '--setup=' === substr($args[0], 0, 8)) { + $setupHook = substr(array_shift($args), 8); +} + +if (!$args) { + fwrite(\STDERR, "usage: php {$argv[0]} [--setup=callable] [...]\n"); + exit(1); +} + +foreach ($args as $featurePath) { + if (!is_file($featurePath)) { + fwrite(\STDERR, "missing: $featurePath\n"); + exit(2); + } + + $src = file_get_contents($featurePath); + $lines = preg_split('/\r?\n/', $src); + + $scenarios = []; + $cur = null; + $inBody = false; + $body = ''; + $bodyTarget = 'json'; + + $flush = static function () use (&$cur, &$scenarios): void { + if (null !== $cur) { + $scenarios[] = $cur; + } + $cur = null; + }; + + foreach ($lines as $line) { + if (preg_match('/^\s*Scenario:\s*(.+)$/', $line, $m)) { + $flush(); + $cur = ['title' => trim($m[1]), 'url' => null, 'httpMethod' => 'GET', 'status' => 200, 'json' => null, 'jsonMode' => null, 'requestBody' => null, 'contentType' => null, 'expectedContentType' => null, 'jsonNodes' => [], 'jsonNodeExists' => []]; + $inBody = false; + $body = ''; + continue; + } + + if (null === $cur) { + continue; + } + + if ($inBody) { + if (preg_match('/^\s*"""\s*$/', $line)) { + if ('requestBody' === $bodyTarget) { + $cur['requestBody'] = trim($body); + } else { + $cur['json'] = trim($body); + } + $inBody = false; + $body = ''; + $bodyTarget = 'json'; + continue; + } + $body .= $line."\n"; + continue; + } + + if (preg_match('/I add "Content-Type" header equal to "([^"]+)"/', $line, $m)) { + $cur['contentType'] = $m[1]; + continue; + } + + if (preg_match('/the header "Content-Type" should be equal to "([^"]+)"/', $line, $m)) { + $cur['expectedContentType'] = $m[1]; + continue; + } + + if (preg_match('/I send a "([A-Z]+)" request to "([^"]+)"\s+with body:\s*$/', $line, $m)) { + $cur['httpMethod'] = $m[1]; + $cur['url'] = $m[2]; + $bodyTarget = 'requestBody'; + continue; + } + + if (preg_match('/I send a "([A-Z]+)" request to "([^"]+)"/', $line, $m)) { + $cur['httpMethod'] = $m[1]; + $cur['url'] = $m[2]; + continue; + } + + if (preg_match('/response status code should be (\d+)/', $line, $m)) { + $cur['status'] = (int) $m[1]; + continue; + } + + if (preg_match('/the JSON node "([^"]+)" should be equal to "([^"]*)"/', $line, $m)) { + $cur['jsonNodes'][$m[1]] = $m[2]; + continue; + } + + if (preg_match("/the JSON node \"([^\"]+)\" should be equal to '([^']*)'/", $line, $m)) { + $cur['jsonNodes'][$m[1]] = $m[2]; + continue; + } + + if (preg_match('/the JSON node "([^"]+)" should exist/', $line, $m)) { + $cur['jsonNodeExists'][] = $m[1]; + continue; + } + + if (preg_match('/JSON should be equal to:\s*$/', $line)) { + $cur['jsonMode'] = 'equals'; + continue; + } + + if (preg_match('/JSON should be a superset of:\s*$/', $line)) { + $cur['jsonMode'] = 'contains'; + continue; + } + + if (preg_match('/JSON should be valid according to this schema:\s*$/', $line)) { + $cur['jsonMode'] = 'schema'; + continue; + } + + if (preg_match('/^\s*"""\s*$/', $line)) { + $inBody = true; + $body = ''; + continue; + } + } + $flush(); + + $used = []; + foreach ($scenarios as $scenario) { + $base = makeMethodName($scenario['title']); + $name = $base; + $i = 2; + while (isset($used[$name])) { + $name = $base.$i; + ++$i; + } + $used[$name] = true; + $scenario['method'] = $name; + $scenario['setupHook'] = $setupHook; + echo emitMethod($scenario); + } +} + +function emitMethod(array $s): string +{ + $method = $s['method'] ?? makeMethodName($s['title']); + $url = $s['url'] ?? ''; + $status = $s['status']; + $httpMethod = $s['httpMethod'] ?? 'GET'; + + $out = "\n public function {$method}(): void\n {\n"; + if (!empty($s['setupHook'])) { + $out .= " \$this->{$s['setupHook']}();\n\n"; + } + $headers = ['Accept' => 'application/ld+json']; + if (!empty($s['contentType'])) { + $headers['Content-Type'] = $s['contentType']; + } + $requestOptions = []; + foreach ($headers as $k => $v) { + $requestOptions['headers'][$k] = $v; + } + if (!empty($s['requestBody'])) { + $requestOptions['body'] = $s['requestBody']; + } + $requestOptionsExport = var_export($requestOptions, true); + $needsResponse = !empty($s['jsonNodeExists']); + $assignment = $needsResponse ? '$response = ' : ''; + $out .= " {$assignment}self::createClient()->request('{$httpMethod}', ".var_export($url, true).", {$requestOptionsExport});\n\n"; + $out .= " \$this->assertResponseStatusCodeSame({$status});\n"; + if (!empty($s['expectedContentType'])) { + $out .= " \$this->assertResponseHeaderSame('content-type', ".var_export($s['expectedContentType'], true).");\n"; + } + + if (null !== $s['json']) { + $heredoc = "<<<'JSON'\n".$s['json']."\nJSON"; + if ('schema' === $s['jsonMode']) { + $out .= " \$this->assertMatchesJsonSchema({$heredoc});\n"; + } else { + $assert = 'contains' === $s['jsonMode'] ? 'assertJsonContains' : 'assertJsonEquals'; + $out .= " \$this->{$assert}({$heredoc});\n"; + } + } + + if (!empty($s['jsonNodes'])) { + $out .= ' $this->assertJsonContains('.var_export($s['jsonNodes'], true).");\n"; + } + + foreach ($s['jsonNodeExists'] as $node) { + $out .= ' $this->assertArrayHasKey('.var_export($node, true).", \$response->toArray(false));\n"; + } + + $out .= " }\n"; + + return $out; +} + +function makeMethodName(string $title): string +{ + $clean = preg_replace('/[^A-Za-z0-9]+/', ' ', $title); + $words = array_filter(array_map('ucfirst', explode(' ', strtolower($clean)))); + + return 'test'.implode('', $words); +} From 425410887969d140ef8d94a6a6d442c4f2dd7d95 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 28 May 2026 15:02:46 +0200 Subject: [PATCH 5/7] chore: drop ad-hoc mongodb deps and the throwaway scaffolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Behat→PHPUnit migration scaffolder under `tools/` was a one-shot helper; it has no place in the published source tree. `doctrine/mongodb-odm` and `doctrine/mongodb-odm-bundle` are installed on demand by the `mongodb` CI job (and likewise on contributor machines), not via require-dev — the lines added in #8202 made them load unconditionally and bloated the install footprint for everyone else. (cherry picked from commit d371f65fb0fc24123b8f0bfd6818e300eb6f64fc) --- composer.json | 2 - tools/feature_to_phpunit.php | 219 ----------------------------------- 2 files changed, 221 deletions(-) delete mode 100644 tools/feature_to_phpunit.php diff --git a/composer.json b/composer.json index 6ae2bbb50fb..7ffc3c950ee 100644 --- a/composer.json +++ b/composer.json @@ -130,8 +130,6 @@ "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", - "doctrine/mongodb-odm": "^2.16", - "doctrine/mongodb-odm-bundle": "^5.6", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", "friends-of-behat/mink-browserkit-driver": "^1.3.1", diff --git a/tools/feature_to_phpunit.php b/tools/feature_to_phpunit.php deleted file mode 100644 index 16bcc82b9eb..00000000000 --- a/tools/feature_to_phpunit.php +++ /dev/null @@ -1,219 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -$args = array_slice($argv, 1); -$setupHook = ''; - -if ($args && '--setup=' === substr($args[0], 0, 8)) { - $setupHook = substr(array_shift($args), 8); -} - -if (!$args) { - fwrite(\STDERR, "usage: php {$argv[0]} [--setup=callable] [...]\n"); - exit(1); -} - -foreach ($args as $featurePath) { - if (!is_file($featurePath)) { - fwrite(\STDERR, "missing: $featurePath\n"); - exit(2); - } - - $src = file_get_contents($featurePath); - $lines = preg_split('/\r?\n/', $src); - - $scenarios = []; - $cur = null; - $inBody = false; - $body = ''; - $bodyTarget = 'json'; - - $flush = static function () use (&$cur, &$scenarios): void { - if (null !== $cur) { - $scenarios[] = $cur; - } - $cur = null; - }; - - foreach ($lines as $line) { - if (preg_match('/^\s*Scenario:\s*(.+)$/', $line, $m)) { - $flush(); - $cur = ['title' => trim($m[1]), 'url' => null, 'httpMethod' => 'GET', 'status' => 200, 'json' => null, 'jsonMode' => null, 'requestBody' => null, 'contentType' => null, 'expectedContentType' => null, 'jsonNodes' => [], 'jsonNodeExists' => []]; - $inBody = false; - $body = ''; - continue; - } - - if (null === $cur) { - continue; - } - - if ($inBody) { - if (preg_match('/^\s*"""\s*$/', $line)) { - if ('requestBody' === $bodyTarget) { - $cur['requestBody'] = trim($body); - } else { - $cur['json'] = trim($body); - } - $inBody = false; - $body = ''; - $bodyTarget = 'json'; - continue; - } - $body .= $line."\n"; - continue; - } - - if (preg_match('/I add "Content-Type" header equal to "([^"]+)"/', $line, $m)) { - $cur['contentType'] = $m[1]; - continue; - } - - if (preg_match('/the header "Content-Type" should be equal to "([^"]+)"/', $line, $m)) { - $cur['expectedContentType'] = $m[1]; - continue; - } - - if (preg_match('/I send a "([A-Z]+)" request to "([^"]+)"\s+with body:\s*$/', $line, $m)) { - $cur['httpMethod'] = $m[1]; - $cur['url'] = $m[2]; - $bodyTarget = 'requestBody'; - continue; - } - - if (preg_match('/I send a "([A-Z]+)" request to "([^"]+)"/', $line, $m)) { - $cur['httpMethod'] = $m[1]; - $cur['url'] = $m[2]; - continue; - } - - if (preg_match('/response status code should be (\d+)/', $line, $m)) { - $cur['status'] = (int) $m[1]; - continue; - } - - if (preg_match('/the JSON node "([^"]+)" should be equal to "([^"]*)"/', $line, $m)) { - $cur['jsonNodes'][$m[1]] = $m[2]; - continue; - } - - if (preg_match("/the JSON node \"([^\"]+)\" should be equal to '([^']*)'/", $line, $m)) { - $cur['jsonNodes'][$m[1]] = $m[2]; - continue; - } - - if (preg_match('/the JSON node "([^"]+)" should exist/', $line, $m)) { - $cur['jsonNodeExists'][] = $m[1]; - continue; - } - - if (preg_match('/JSON should be equal to:\s*$/', $line)) { - $cur['jsonMode'] = 'equals'; - continue; - } - - if (preg_match('/JSON should be a superset of:\s*$/', $line)) { - $cur['jsonMode'] = 'contains'; - continue; - } - - if (preg_match('/JSON should be valid according to this schema:\s*$/', $line)) { - $cur['jsonMode'] = 'schema'; - continue; - } - - if (preg_match('/^\s*"""\s*$/', $line)) { - $inBody = true; - $body = ''; - continue; - } - } - $flush(); - - $used = []; - foreach ($scenarios as $scenario) { - $base = makeMethodName($scenario['title']); - $name = $base; - $i = 2; - while (isset($used[$name])) { - $name = $base.$i; - ++$i; - } - $used[$name] = true; - $scenario['method'] = $name; - $scenario['setupHook'] = $setupHook; - echo emitMethod($scenario); - } -} - -function emitMethod(array $s): string -{ - $method = $s['method'] ?? makeMethodName($s['title']); - $url = $s['url'] ?? ''; - $status = $s['status']; - $httpMethod = $s['httpMethod'] ?? 'GET'; - - $out = "\n public function {$method}(): void\n {\n"; - if (!empty($s['setupHook'])) { - $out .= " \$this->{$s['setupHook']}();\n\n"; - } - $headers = ['Accept' => 'application/ld+json']; - if (!empty($s['contentType'])) { - $headers['Content-Type'] = $s['contentType']; - } - $requestOptions = []; - foreach ($headers as $k => $v) { - $requestOptions['headers'][$k] = $v; - } - if (!empty($s['requestBody'])) { - $requestOptions['body'] = $s['requestBody']; - } - $requestOptionsExport = var_export($requestOptions, true); - $needsResponse = !empty($s['jsonNodeExists']); - $assignment = $needsResponse ? '$response = ' : ''; - $out .= " {$assignment}self::createClient()->request('{$httpMethod}', ".var_export($url, true).", {$requestOptionsExport});\n\n"; - $out .= " \$this->assertResponseStatusCodeSame({$status});\n"; - if (!empty($s['expectedContentType'])) { - $out .= " \$this->assertResponseHeaderSame('content-type', ".var_export($s['expectedContentType'], true).");\n"; - } - - if (null !== $s['json']) { - $heredoc = "<<<'JSON'\n".$s['json']."\nJSON"; - if ('schema' === $s['jsonMode']) { - $out .= " \$this->assertMatchesJsonSchema({$heredoc});\n"; - } else { - $assert = 'contains' === $s['jsonMode'] ? 'assertJsonContains' : 'assertJsonEquals'; - $out .= " \$this->{$assert}({$heredoc});\n"; - } - } - - if (!empty($s['jsonNodes'])) { - $out .= ' $this->assertJsonContains('.var_export($s['jsonNodes'], true).");\n"; - } - - foreach ($s['jsonNodeExists'] as $node) { - $out .= ' $this->assertArrayHasKey('.var_export($node, true).", \$response->toArray(false));\n"; - } - - $out .= " }\n"; - - return $out; -} - -function makeMethodName(string $title): string -{ - $clean = preg_replace('/[^A-Za-z0-9]+/', ' ', $title); - $words = array_filter(array_map('ucfirst', explode(' ', strtolower($clean)))); - - return 'test'.implode('', $words); -} From 9f388c9a398bcb34d621381881a1b35f3c4352cb Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 17:50:53 +0200 Subject: [PATCH 6/7] test: migrate doctrine/graphql behat features to ApiTestCase (#8205) (cherry picked from commit 4911cfd1402b7fecb2389b3392361b0afe496f63) --- .github/workflows/ci.yml | 342 +-- AGENTS.md | 9 +- CONTRIBUTING.md | 21 +- behat.yml.dist | 219 -- composer.json | 6 - features/doctrine/boolean_filter.feature | 525 ---- features/doctrine/date_filter.feature | 636 ---- features/doctrine/eager_loading.feature | 99 - features/doctrine/exists_filter.feature | 223 -- features/doctrine/handle_links.feature | 17 - .../issue5722/subresource_without_get.feature | 8 - .../standard_put_entity_inheritence.feature | 25 - features/doctrine/multiple_filter.feature | 48 - features/doctrine/numeric_filter.feature | 219 -- features/doctrine/order_filter.feature | 824 ----- features/doctrine/range_filter.feature | 506 --- features/doctrine/search_filter.feature | 1066 ------- features/doctrine/separated_resource.feature | 116 - features/graphql/authorization.feature | 576 ---- features/graphql/collection.feature | 1109 ------- features/graphql/docs.feature | 10 - features/graphql/filters.feature | 302 -- features/graphql/input_output.feature | 202 -- features/graphql/introspection.feature | 621 ---- features/graphql/mutation.feature | 1071 ------- features/graphql/query.feature | 696 ----- features/graphql/schema.feature | 113 - features/graphql/subscription.feature | 224 -- features/graphql/type.feature | 80 - phpunit.xml.dist | 2 - src/Doctrine/Odm/Tests/AppKernel.php | 1 - src/Doctrine/Orm/Tests/AppKernel.php | 1 - src/GraphQl/Test/GraphQlTestTrait.php | 141 + tests/AGENTS.md | 6 - tests/Behat/CommandContext.php | 106 - tests/Behat/CoverageContext.php | 92 - tests/Behat/DoctrineContext.php | 2707 ----------------- tests/Behat/GraphqlContext.php | 178 -- tests/Behat/HttpCacheContext.php | 91 - tests/Behat/HydraContext.php | 326 -- tests/Behat/JsonApiContext.php | 209 -- tests/Behat/JsonContext.php | 112 - tests/Behat/JsonHalContext.php | 80 - tests/Behat/MercureContext.php | 144 - tests/Behat/XmlContext.php | 43 - .../TestBundle/Document/AbsoluteUrlDummy.php | 2 +- .../TestBundle/Document/NetworkPathDummy.php | 2 +- .../TestBundle/Entity/AbsoluteUrlDummy.php | 2 +- .../TestBundle/Entity/DummyAggregateOffer.php | 4 +- .../Fixtures/TestBundle/Entity/DummyOffer.php | 6 +- .../TestBundle/Entity/DummyProduct.php | 2 +- .../DummyResourceWithComplexConstructor.php | 1 + tests/Fixtures/TestBundle/Entity/Greeting.php | 2 +- .../TestBundle/Entity/NetworkPathDummy.php | 2 +- .../MessengerHandler/Document/RPCHandler.php | 25 + tests/Fixtures/app/AppKernel.php | 12 - tests/Fixtures/app/bootstrap.php | 7 + .../app/config/config_behat_mongodb.yml | 17 - .../Fixtures/app/config/config_behat_orm.yml | 18 - tests/Fixtures/app/config/config_mongodb.yml | 6 + .../Functional/Doctrine/BooleanFilterTest.php | 264 ++ tests/Functional/Doctrine/DateFilterTest.php | 385 +++ .../Functional/Doctrine/EagerLoadingTest.php | 300 ++ .../Functional/Doctrine/ExistsFilterTest.php | 201 ++ tests/Functional/Doctrine/LinkHandlerTest.php | 74 + .../Doctrine/MappedSuperclassPutTest.php | 62 + .../Doctrine/MultipleFilterTest.php | 89 + .../Functional/Doctrine/NumericFilterTest.php | 146 + tests/Functional/Doctrine/OrderFilterTest.php | 314 ++ tests/Functional/Doctrine/RangeFilterTest.php | 123 + .../Functional/Doctrine/SearchFilterTest.php | 802 +++++ .../Doctrine/SeparatedResourceTest.php | 146 + .../EnumDenormalizationValidationTest.php | 7 + .../Functional/GraphQl/AuthorizationTest.php | 590 ++++ tests/Functional/GraphQl/CollectionTest.php | 923 ++++++ tests/Functional/GraphQl/CustomTypeTest.php | 134 + tests/Functional/GraphQl/DocsTest.php | 29 + tests/Functional/GraphQl/FilterTest.php | 528 ++++ .../Functional/GraphQl/Fixtures}/test.gif | Bin tests/Functional/GraphQl/InputOutputTest.php | 236 ++ .../Functional/GraphQl/IntrospectionTest.php | 487 +++ tests/Functional/GraphQl/MutationTest.php | 955 ++++++ tests/Functional/GraphQl/QueryTest.php | 852 ++++++ tests/Functional/GraphQl/SchemaExportTest.php | 174 ++ tests/Functional/GraphQl/SubscriptionTest.php | 254 ++ tests/Functional/MappingTest.php | 2 +- .../NullOnNonNullablePropertyTest.php | 8 + .../SubResource/SubResourceTest.php | 6 +- .../SubResource/SubResourceWithoutGetTest.php | 64 + tests/RecreateSchemaTrait.php | 2 +- tests/SetupClassResourcesTrait.php | 1 + tests/TestSuiteConfigCache.php | 4 +- tests/WithResourcesTrait.php | 45 + 93 files changed, 8417 insertions(+), 14050 deletions(-) delete mode 100644 behat.yml.dist delete mode 100644 features/doctrine/boolean_filter.feature delete mode 100644 features/doctrine/date_filter.feature delete mode 100644 features/doctrine/eager_loading.feature delete mode 100644 features/doctrine/exists_filter.feature delete mode 100644 features/doctrine/handle_links.feature delete mode 100644 features/doctrine/issue5722/subresource_without_get.feature delete mode 100644 features/doctrine/issue6175/standard_put_entity_inheritence.feature delete mode 100644 features/doctrine/multiple_filter.feature delete mode 100644 features/doctrine/numeric_filter.feature delete mode 100644 features/doctrine/order_filter.feature delete mode 100644 features/doctrine/range_filter.feature delete mode 100644 features/doctrine/search_filter.feature delete mode 100644 features/doctrine/separated_resource.feature delete mode 100644 features/graphql/authorization.feature delete mode 100644 features/graphql/collection.feature delete mode 100644 features/graphql/docs.feature delete mode 100644 features/graphql/filters.feature delete mode 100644 features/graphql/input_output.feature delete mode 100644 features/graphql/introspection.feature delete mode 100644 features/graphql/mutation.feature delete mode 100644 features/graphql/query.feature delete mode 100644 features/graphql/schema.feature delete mode 100644 features/graphql/subscription.feature delete mode 100644 features/graphql/type.feature create mode 100644 src/GraphQl/Test/GraphQlTestTrait.php delete mode 100644 tests/Behat/CommandContext.php delete mode 100644 tests/Behat/CoverageContext.php delete mode 100644 tests/Behat/DoctrineContext.php delete mode 100644 tests/Behat/GraphqlContext.php delete mode 100644 tests/Behat/HttpCacheContext.php delete mode 100644 tests/Behat/HydraContext.php delete mode 100644 tests/Behat/JsonApiContext.php delete mode 100644 tests/Behat/JsonContext.php delete mode 100644 tests/Behat/JsonHalContext.php delete mode 100644 tests/Behat/MercureContext.php delete mode 100644 tests/Behat/XmlContext.php create mode 100644 tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php delete mode 100644 tests/Fixtures/app/config/config_behat_mongodb.yml delete mode 100644 tests/Fixtures/app/config/config_behat_orm.yml create mode 100644 tests/Functional/Doctrine/BooleanFilterTest.php create mode 100644 tests/Functional/Doctrine/DateFilterTest.php create mode 100644 tests/Functional/Doctrine/EagerLoadingTest.php create mode 100644 tests/Functional/Doctrine/ExistsFilterTest.php create mode 100644 tests/Functional/Doctrine/LinkHandlerTest.php create mode 100644 tests/Functional/Doctrine/MappedSuperclassPutTest.php create mode 100644 tests/Functional/Doctrine/MultipleFilterTest.php create mode 100644 tests/Functional/Doctrine/NumericFilterTest.php create mode 100644 tests/Functional/Doctrine/OrderFilterTest.php create mode 100644 tests/Functional/Doctrine/RangeFilterTest.php create mode 100644 tests/Functional/Doctrine/SearchFilterTest.php create mode 100644 tests/Functional/Doctrine/SeparatedResourceTest.php create mode 100644 tests/Functional/GraphQl/AuthorizationTest.php create mode 100644 tests/Functional/GraphQl/CollectionTest.php create mode 100644 tests/Functional/GraphQl/CustomTypeTest.php create mode 100644 tests/Functional/GraphQl/DocsTest.php create mode 100644 tests/Functional/GraphQl/FilterTest.php rename {features/files => tests/Functional/GraphQl/Fixtures}/test.gif (100%) create mode 100644 tests/Functional/GraphQl/InputOutputTest.php create mode 100644 tests/Functional/GraphQl/IntrospectionTest.php create mode 100644 tests/Functional/GraphQl/MutationTest.php create mode 100644 tests/Functional/GraphQl/QueryTest.php create mode 100644 tests/Functional/GraphQl/SchemaExportTest.php create mode 100644 tests/Functional/GraphQl/SubscriptionTest.php create mode 100644 tests/Functional/SubResource/SubResourceWithoutGetTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 021767ec54b..62d9ce3f8ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -447,94 +447,8 @@ jobs: cd $(composer ${{matrix.component}} --cwd) ./vendor/bin/phpunit --fail-on-deprecation --display-deprecations --log-junit "/tmp/build/logs/phpunit/junit.xml" - behat: - name: Behat (PHP ${{ matrix.php }} ${{ matrix.shard }}) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} - shard: - - graphql-doctrine - include: - - php: '8.5' - shard: graphql-doctrine - coverage: true - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite - coverage: pcov - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Resolve shard paths - id: shard - run: | - case "${{ matrix.shard }}" in - graphql-doctrine) paths="features/graphql features/doctrine" ;; - esac - echo "paths=$paths" >> $GITHUB_OUTPUT - - name: Run Behat tests (PHP ${{ matrix.php }} ${{ matrix.shard }}) - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --no-interaction ${{ matrix.coverage && '--profile=default-coverage' || '--profile=default' }} ${{ steps.shard.outputs.paths }} - - name: Merge code coverage reports - if: matrix.coverage - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v6 - with: - name: behat-logs-php${{ matrix.php }}-shard${{ matrix.shard }} - path: build/logs/behat - continue-on-error: true - - name: Upload coverage results to Codecov - if: matrix.coverage - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }}-shard${{ matrix.shard }} - flags: behat - fail_ci_if_error: true - continue-on-error: true - - name: Upload coverage results to Coveralls - if: matrix.coverage - env: - COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls - export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml - continue-on-error: true - postgresql: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (PostgreSQL) + name: PHPUnit (PHP ${{ matrix.php }}) (PostgreSQL) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -581,14 +495,9 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: | - vendor/bin/behat --out=std --format=progress --profile=postgres --no-interaction -vv mysql: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (MySQL) + name: PHPUnit (PHP ${{ matrix.php }}) (MySQL) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -636,13 +545,9 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags '~@!mysql' mongodb: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (MongoDB) + name: PHPUnit (PHP ${{ matrix.php }}) (MongoDB) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -692,33 +597,20 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit --log-junit build/logs/phpunit/junit.xml --coverage-clover build/logs/phpunit/clover.xml --exclude-group=orm - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb-coverage --no-interaction - - name: Merge code coverage reports - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - continue-on-error: true - name: Upload test artifacts if: always() uses: actions/upload-artifact@v6 with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat + name: phpunit-logs-php${{ matrix.php }}-mongodb + path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }} - flags: behat + directory: build/logs/phpunit + name: phpunit-php${{ matrix.php }}-mongodb + flags: phpunit fail_ci_if_error: true continue-on-error: true - name: Upload coverage results to Coveralls @@ -727,11 +619,11 @@ jobs: run: | composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml + php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true mercure: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (Mercure) + name: PHPUnit (PHP ${{ matrix.php }}) (Mercure) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -785,31 +677,20 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit --log-junit build/logs/phpunit/junit.xml --coverage-clover build/logs/phpunit/clover.xml --group mercure - - name: Run Behat tests - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mercure-coverage --no-interaction - - name: Merge code coverage reports - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - continue-on-error: true - name: Upload test artifacts if: always() uses: actions/upload-artifact@v6 with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat + name: phpunit-logs-php${{ matrix.php }}-mercure + path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }} - flags: behat + directory: build/logs/phpunit + name: phpunit-php${{ matrix.php }}-mercure + flags: phpunit fail_ci_if_error: true continue-on-error: true - name: Upload coverage results to Coveralls @@ -818,7 +699,7 @@ jobs: run: | composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml + php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true elasticsearch: @@ -1029,50 +910,6 @@ jobs: - name: Run PHPUnit tests run: vendor/bin/phpunit --fail-on-deprecation - behat-symfony-next: - name: Behat (PHP ${{ matrix.php }}) (Symfony dev) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Allow unstable project dependencies - run: composer config minimum-stability dev - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Remove cache - run: rm -Rf tests/Fixtures/app/var/cache/* - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction - - # remove once behat can be installed with symfony 8.1 phpunit-symfony-edge: name: PHPUnit (PHP ${{ matrix.php }}) (Symfony 8.1) runs-on: ubuntu-latest @@ -1099,8 +936,6 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Allow unstable project dependencies run: composer config minimum-stability dev - - name: Drop Behat dev dependencies (incompatible with Symfony 8.1) - run: composer remove --no-update --no-interaction --dev behat/behat behat/mink soyuka/contexts friends-of-behat/symfony-extension friends-of-behat/mink-browserkit-driver friends-of-behat/mink-extension - name: Force Symfony 8.1 dev for framework-bundle and json-streamer run: composer require --dev --no-update --no-interaction "symfony/framework-bundle:8.1.x-dev" "symfony/json-streamer:8.1.x-dev" - name: Cache dependencies @@ -1121,59 +956,6 @@ jobs: - name: Run PHPUnit tests run: vendor/bin/phpunit - windows-behat: - name: Windows Behat (PHP ${{ matrix.php }}) (SQLite) - runs-on: windows-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - env: - APP_ENV: sqlite - DATABASE_URL: sqlite:///%kernel.project_dir%/var/data.db - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP with pre-release PECL extension - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, fileinfo - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - shell: bash - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Keep windows path - id: get-cwd - shell: bash - run: | - cwd=$(php -r 'echo(str_replace("\\", "\\\\", $_SERVER["argv"][1]));' '${{ github.workspace }}') - echo cwd=$cwd >> $GITHUB_OUTPUT - - name: Update project dependencies - shell: bash - run: | - php -m - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . --working-directory='${{ steps.get-cwd.outputs.cwd }}' - - name: Clear test app cache - shell: bash - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - shell: bash - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction - phpunit-symfony-lowest: name: PHPUnit (PHP ${{ matrix.php }}) (Symfony lowest) runs-on: ubuntu-latest @@ -1218,48 +1000,6 @@ jobs: env: SYMFONY_DEPRECATIONS_HELPER: max[self]=0&ignoreFile=./tests/.ignored-deprecations - behat-symfony-lowest: - name: Behat (PHP ${{ matrix.php }}) (Symfony lowest) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Remove cache - run: rm -Rf tests/Fixtures/app/var/cache/* - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . --permanent - composer update --prefer-lowest - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags='~@disableForSymfonyLowest' - phpunit_listeners: name: PHPUnit event listeners (PHP ${{ matrix.php }}) env: @@ -1339,56 +1079,6 @@ jobs: php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true - behat_listeners: - name: Behat event listeners (PHP ${{ matrix.php }}) - env: - USE_SYMFONY_LISTENERS: 1 - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite - coverage: pcov - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests (PHP 8) - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=symfony_listeners --no-interaction - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v6 - with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat - continue-on-error: true - openapi: name: OpenAPI runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 4f0d29e0b27..8f0adddc594 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ You are an expert Core Contributor to API Platform, a PHP framework supporting S * Context Retrieval (VectorCode): Before writing new code or asking for clarification, ALWAYS use vectorcode if available to search for existing patterns, interfaces, or similar implementations in the codebase. * Test-First Mandate: Your primary output should be functional tests to expose bugs or verify features. Do not fix bugs unless explicitly requested. -* Execution Restraint: NEVER run the full test suite (Behat or PHPUnit). It is too slow. Only run specific, filtered tests relevant to the current task. +* Execution Restraint: NEVER run the full PHPUnit test suite. It is too slow. Only run specific, filtered tests relevant to the current task. * Fixture Isolation: Do not modify existing fixtures (tests/Fixtures/...). Always create new Entities, DTOs, or Models to prevent regression in other tests. * Git Policy: Do not perform git commits unless explicitly asked. @@ -26,7 +26,7 @@ When to use: 3. Testing Quick-Reference (Default/Symfony) -For advanced configurations (Event Listeners, MongoDB, Behat tuning), refer to `tests/AGENTS.md`. +For advanced configurations (Event Listeners, MongoDB), refer to `tests/AGENTS.md`. Common Commands: @@ -43,12 +43,9 @@ rm -rf tests/Fixtures/app/var/cache/test # indefinitely. Remove them before running tests: find src -name vendor -exec rm -rf {} + -# PHPUnit (Preferred) +# PHPUnit vendor/bin/phpunit --filter testMethodName -# Behat (Legacy) -vendor/bin/behat features/main/crud.feature:120 --format=progress - #Component Testing cd src/Metadata composer link ../../ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff0d70a8cd9..b9579d92a29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ See also the [related documentation for Symfony](https://symfony.com/doc/current When you send a PR, just make sure that: -* You add valid test cases (Behat and PHPUnit). +* You add valid test cases (PHPUnit). * Tests are green. * You make a PR on the related documentation in the [api-platform/docs](https://github.com/api-platform/docs) repository. * You make the PR on the same branch you based your changes on. If you see commits @@ -123,11 +123,11 @@ Only the first commit on a Pull Request need to use a conventional commit, other ### Tests -On `api-platform/core` there are two kinds of tests: unit (`phpunit`) and integration tests (`behat`). +On `api-platform/core` tests are written with `phpunit` (unit tests and functional tests under `tests/Functional`). Note that we stopped using `prophesize` for new tests since 3.2, use `phpunit` stub system. -Both `phpunit` and `behat` are development dependencies and should be available in the `vendor` directory. +`phpunit` is a development dependency and should be available in the `vendor` directory. Recommendations: @@ -157,20 +157,11 @@ Sometimes there might be an error with too many open files when generating cover Coverage will be available in `coverage/index.html`. -#### Behat +To run functional tests for MongoDB: -> [!WARNING] -> Please **do not add new Behat tests**, use a functional test (for example: [ComputedFieldTest](https://github.com/api-platform/core/blob/04d5cff1b28b494ac2e90257a79ce6c045ba82ae/tests/Functional/Doctrine/ComputedFieldTest.php)). + MONGODB_URL=mongodb://localhost:27017 APP_ENV=mongodb vendor/bin/phpunit --group mongodb -The command to launch Behat tests is: - - php -d memory_limit=-1 ./vendor/bin/behat --profile=default --stop-on-failure --format=progress - -If you want to launch Behat tests for MongoDB, the command is: - - MONGODB_URL=mongodb://localhost:27017 APP_ENV=mongodb php -d memory_limit=-1 ./vendor/bin/behat --profile=mongodb --stop-on-failure --format=progress - -To get more details about an error, replace `--format=progress` by `-vvv`. You may run a mongo instance using docker: +You may run a mongo instance using docker: docker run -p 27017:27017 mongo:latest diff --git a/behat.yml.dist b/behat.yml.dist deleted file mode 100644 index a771434c534..00000000000 --- a/behat.yml.dist +++ /dev/null @@ -1,219 +0,0 @@ -default: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@controller&&~@mercure&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ - -postgres: - suites: - default: false - postgres: &postgres-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@sqlite&&~@mongodb&&~@elasticsearch&&~@controller&&~@mercure&&~@query_parameter_validator' - -mongodb: - suites: - default: false - mongodb: &mongodb-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@sqlite&&~@elasticsearch&&~@!mongodb&&~@mercure&&~@controller&&~@query_parameter_validator' - -mercure: - suites: - default: false - mercure: &mercure-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '@mercure' - -default-coverage: - suites: - default: &default-coverage-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -mongodb-coverage: - suites: - default: false - mongodb: &mongodb-coverage-suite - <<: *mongodb-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -mercure-coverage: - suites: - default: false - mongodb: &mercure-coverage-suite - <<: *mercure-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -legacy: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@link_security&&~@use_listener&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ - -symfony_listeners: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@mercure&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ diff --git a/composer.json b/composer.json index 7ffc3c950ee..89a068313e3 100644 --- a/composer.json +++ b/composer.json @@ -125,16 +125,11 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "behat/behat": "^3.11", - "behat/mink": "^1.9", "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", - "friends-of-behat/mink-browserkit-driver": "^1.3.1", - "friends-of-behat/mink-extension": "^2.2", - "friends-of-behat/symfony-extension": "^2.1", "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", "illuminate/config": "^11.0 || ^12.0 || ^13.0", @@ -160,7 +155,6 @@ "psr/log": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "soyuka/contexts": "^3.3.10", "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", "symfony/asset": "^6.4 || ^7.0 || ^8.0", diff --git a/features/doctrine/boolean_filter.feature b/features/doctrine/boolean_filter.feature deleted file mode 100644 index e3332eea7bd..00000000000 --- a/features/doctrine/boolean_filter.feature +++ /dev/null @@ -1,525 +0,0 @@ -Feature: Boolean filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections with boolean value - - @createSchema - Scenario: Get collection by dummyBoolean true - Given there are 15 dummy objects with dummyBoolean true - And there are 10 dummy objects with dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by dummyBoolean true - When I send a "GET" request to "/dummies?dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/16$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/16$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by embeddedDummy.dummyBoolean true - Given there are 15 embedded dummy objects with embeddedDummy.dummyBoolean true - And there are 10 embedded dummy objects with embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by embeddedDummy.dummyBoolean true - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/16$"}, - {"pattern": "^/embedded_dummies/17$"}, - {"pattern": "^/embedded_dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/16$"}, - {"pattern": "^/embedded_dummies/17$"}, - {"pattern": "^/embedded_dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by association with embed relatedDummy.embeddedDummy.dummyBoolean true - Given there are 15 embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean true - And there are 10 embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?relatedDummy.embeddedDummy.dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/26$"}, - {"pattern": "^/embedded_dummies/27$"}, - {"pattern": "^/embedded_dummies/28$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?relatedDummy.embeddedDummy\\.dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection filtered by non valid properties - When I send a "GET" request to "/dummies?unknown=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 25 - - When I send a "GET" request to "/dummies?unknown=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 25 - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedBoolean objects - When I send a "GET" request to "/converted_booleans?name_converted=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedBoolean"}, - "@id": {"pattern": "^/converted_booleans"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_booleans/(2|4)$"}, - "@type": {"pattern": "^ConvertedBoolean"}, - "name_converted": {"type": "boolean"}, - "id": {"type": "integer", "minimum":2, "maximum": 4} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_booleans\\?name_converted=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_booleans\\{\\?name_converted\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/date_filter.feature b/features/doctrine/date_filter.feature deleted file mode 100644 index 7d8d1a906f8..00000000000 --- a/features/doctrine/date_filter.feature +++ /dev/null @@ -1,636 +0,0 @@ -Feature: Date filter on collections - In order to retrieve large collections of resources filtered by date - As a client software developer - I need to retrieve collections filtered by date - - @createSchema - Scenario: Get collection filtered by date - Given there are 30 dummy objects with dummyDate - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 5, "maximum": 5}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"] - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28T00:00:00%2B00:00" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-28T00%3A00%3A00%2B00%3A00$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05Z" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 5, "maximum": 5}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05Z&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"] - } - } - } - """ - - Scenario: Search for entities within a range - # The order should not influence the search - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05&dummyDate[after]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/5$"} - }, - "required": ["@id"] - }, - "minItems": 1, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05&dummyDate%5Bafter%5D=2015-04-05$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-05&dummyDate[before]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/5$"} - }, - "required": ["@id"] - }, - "minItems": 1, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-05&dummyDate%5Bbefore%5D=2015-04-05$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - Scenario: Search for entities within an impossible range - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-06&dummyDate[before]=2015-04-04" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-06&dummyDate%5Bbefore%5D=2015-04-04$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - Scenario: Get collection filtered by association date - Given there are 30 dummy objects with dummyDate and relatedDummy - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28&relatedDummy_dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28&relatedDummy_dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28T00:00:00%2B00:00" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28T00%3A00%3A00%2B00%3A00$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by association date - Given there are 2 dummy objects with dummyDate and relatedDummy - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:totalItems": {"type":"number", "maximum": 0}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by date that is not a datetime - Given there are 30 dummydate objects with dummyDate - When I send a "GET" request to "/dummy_dates?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the JSON node "hydra:totalItems" should be equal to 3 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null after - Given there are 3 dummydate objects with nullable dateIncludeNullAfter - When I send a "GET" request to "/dummy_dates?dateIncludeNullAfter[after]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullAfter" should be equal to "2015-04-02T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullAfter" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullAfter[before]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullAfter" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullAfter" should be equal to "2015-04-02T00:00:00+00:00" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null before - Given there are 3 dummydate objects with nullable dateIncludeNullBefore - When I send a "GET" request to "/dummy_dates?dateIncludeNullBefore[before]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBefore" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBefore" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullBefore[after]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBefore" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBefore" should be equal to "2015-04-02T00:00:00+00:00" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null before and after - Given there are 3 dummydate objects with nullable dateIncludeNullBeforeAndAfter - When I send a "GET" request to "/dummy_dates?dateIncludeNullBeforeAndAfter[before]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBeforeAndAfter" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBeforeAndAfter" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullBeforeAndAfter[after]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBeforeAndAfter" should be equal to "2015-04-02T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBeforeAndAfter" should be null - - @createSchema - Scenario: Get collection filtered by date that is an immutable date variant - Given there are 30 dummyimmutabledate objects with dummyDate - When I send a "GET" request to "/dummy_immutable_dates?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the JSON node "hydra:totalItems" should be equal to 3 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @createSchema - Scenario: Get collection filtered by embedded date - Given there are 29 embedded dummy objects with dummyDate and embeddedDummy - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/28$"}, - {"pattern": "^/embedded_dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 30 convertedDate objects - When I send a "GET" request to "/converted_dates?name_converted[strictly_after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedDate"}, - "@id": {"pattern": "^/converted_dates"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_dates/(29|30)$"}, - "@type": {"pattern": "^ConvertedDate"}, - "name_converted": {"type": "string"}, - "id": {"type": "integer", "minimum":29, "maximum": 30} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_dates\\?name_converted%5Bstrictly_after%5D=2015\\-04\\-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_dates\\{\\?.*name_converted\\[before\\],name_converted\\[strictly_before\\],name_converted\\[after\\],name_converted\\[strictly_after\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted(\\[(strictly_)?(before|after)\\])$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 4, - "maxItems": 4, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/eager_loading.feature b/features/doctrine/eager_loading.feature deleted file mode 100644 index ee7e73ff6a1..00000000000 --- a/features/doctrine/eager_loading.feature +++ /dev/null @@ -1,99 +0,0 @@ -@!mongodb -Feature: Eager Loading - In order to have better performance - As a client software developer - The eager loading should be enabled - - @createSchema - Scenario: Eager loading for a relation - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies/1" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a1, relatedToDummyFriend_a3, fourthLevel_a2, dummyFriend_a4 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - LEFT JOIN o.thirdLevel thirdLevel_a1 - LEFT JOIN thirdLevel_a1.fourthLevel fourthLevel_a2 - LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a3 - LEFT JOIN relatedToDummyFriend_a3.dummyFriend dummyFriend_a4 - WHERE o.id = :id_p1 - """ - - Scenario: Eager loading for the search filter - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.level=3" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o - INNER JOIN o.relatedDummy relatedDummy_a1 - INNER JOIN relatedDummy_a1.thirdLevel thirdLevel_a2 - WHERE o IN( - SELECT o_a3 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o_a3 - INNER JOIN o_a3.relatedDummy relatedDummy_a4 - INNER JOIN relatedDummy_a4.thirdLevel thirdLevel_a5 - WHERE thirdLevel_a5.level = :level_p1 - ) - ORDER BY o.id ASC - """ - - Scenario: Eager loading for a relation and a search filter - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies?relatedToDummyFriend.dummyFriend=2" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, fourthLevel_a5, dummyFriend_a6 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1 - LEFT JOIN o.thirdLevel thirdLevel_a4 - LEFT JOIN thirdLevel_a4.fourthLevel fourthLevel_a5 - INNER JOIN relatedToDummyFriend_a1.dummyFriend dummyFriend_a6 - WHERE o IN( - SELECT o_a2 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o_a2 - INNER JOIN o_a2.relatedToDummyFriend relatedToDummyFriend_a3 - WHERE relatedToDummyFriend_a3.dummyFriend = :dummyFriend_p1 - ) - ORDER BY o.id ASC - """ - - Scenario: Eager loading for a relation and a property filter with multiple relations - Given there is a dummy travel - When I send a "GET" request to "/dummy_travels/1?properties[]=confirmed&properties[car][]=brand&properties[passenger][]=nickname" - Then the response status code should be 200 - And the JSON node "confirmed" should be equal to "true" - And the JSON node "car.carBrand" should be equal to "DummyBrand" - And the JSON node "passenger.nickname" should be equal to "Tom" - And the DQL should be equal to: - """ - SELECT o, car_a1, passenger_a2 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel o - LEFT JOIN o.car car_a1 - LEFT JOIN o.passenger passenger_a2 - WHERE o.id = :id_p1 - """ - - Scenario: Eager loading for a relation with complex sub-query filter - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies?complex_sub_query_filter=1" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a3, relatedToDummyFriend_a5, fourthLevel_a4, dummyFriend_a6 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - LEFT JOIN o.thirdLevel thirdLevel_a3 - LEFT JOIN thirdLevel_a3.fourthLevel fourthLevel_a4 - LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a5 - LEFT JOIN relatedToDummyFriend_a5.dummyFriend dummyFriend_a6 - WHERE o.id IN ( - SELECT related_dummy_a1.id - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy related_dummy_a1 - INNER JOIN related_dummy_a1.relatedToDummyFriend related_to_dummy_friend_a2 - WITH related_to_dummy_friend_a2.name = :name_p1 - ) - ORDER BY o.id ASC - """ diff --git a/features/doctrine/exists_filter.feature b/features/doctrine/exists_filter.feature deleted file mode 100644 index e99f2ff445a..00000000000 --- a/features/doctrine/exists_filter.feature +++ /dev/null @@ -1,223 +0,0 @@ -Feature: Exists filter on collections - In order to retrieve large collections of resources - As a client software developer - I need to retrieve collections with properties that exist or not - - @createSchema - Scenario: Get collection where a property does not exist - Given there are 15 dummy objects with dummyBoolean true - When I send a "GET" request to "/dummies?exists[dummyBoolean]=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 0}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BdummyBoolean%5D=0$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection where a property does exist - When I send a "GET" request to "/dummies?exists[dummyBoolean]=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 15, "maximum": 15}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(1|2|3)$"} - }, - "required": ["@id"] - }, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BdummyBoolean%5D=1&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Use exists filter with a empty relation collection - Given there are 3 dummy objects having each 0 relatedDummies - And there are 2 dummy objects having each 3 relatedDummies - When I send a "GET" request to "/dummies?exists[relatedDummies]=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(1|2|3)$"} - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BrelatedDummies%5D=0$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Use exists filter with a non empty relation collection - When I send a "GET" request to "/dummies?exists[relatedDummies]=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(4|5)$"} - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BrelatedDummies%5D=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 4 convertedString objects - When I send a "GET" request to "/converted_strings?exists[name_converted]=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedString"}, - "@id": {"pattern": "^/converted_strings"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_strings/(1|3)$"}, - "@type": {"pattern": "^ConvertedString"}, - "name_converted": {"pattern": "^name#(1|3)$"}, - "id": {"type": "integer", "minimum":1, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_strings\\?exists%5Bname_converted%5D=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_strings\\{\\?exists\\[name_converted\\]\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^exists\\[name_converted\\]$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/handle_links.feature b/features/doctrine/handle_links.feature deleted file mode 100644 index ebfa7b10e4f..00000000000 --- a/features/doctrine/handle_links.feature +++ /dev/null @@ -1,17 +0,0 @@ -Feature: Use a link handler to retrieve a resource - - @createSchema - Scenario: Get collection - Given there are a few link handled dummies - When I send a "GET" request to "/link_handled_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @createSchema - Scenario: Get item - Given there are a few link handled dummies - When I send a "GET" request to "/link_handled_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "slug" should be equal to "foo" diff --git a/features/doctrine/issue5722/subresource_without_get.feature b/features/doctrine/issue5722/subresource_without_get.feature deleted file mode 100644 index ff54949e926..00000000000 --- a/features/doctrine/issue5722/subresource_without_get.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: Get a subresource from inverse side that has no item operation - - @!mongodb - @createSchema - Scenario: Get a subresource from inverse side that has no item operation - Given there are logs on an event - When I send a "GET" request to "/events/03af3507-271e-4cca-8eee-6244fb06e95b/logs" - Then the response status code should be 200 diff --git a/features/doctrine/issue6175/standard_put_entity_inheritence.feature b/features/doctrine/issue6175/standard_put_entity_inheritence.feature deleted file mode 100644 index 07d0d7e88cb..00000000000 --- a/features/doctrine/issue6175/standard_put_entity_inheritence.feature +++ /dev/null @@ -1,25 +0,0 @@ -Feature: Update properties of a resource that are inherited with standard PUT operation - - @!mongodb - @createSchema - Scenario: Update properties of a resource that are inherited with standard PUT operation - Given there is a dummy entity with a mapped superclass - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mapped_subclasses/1" with body: - """ - { - "foo": "updated value" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyMappedSubclass", - "@id": "/dummy_mapped_subclasses/1", - "@type": "DummyMappedSubclass", - "id": 1, - "foo": "updated value" - } - """ diff --git a/features/doctrine/multiple_filter.feature b/features/doctrine/multiple_filter.feature deleted file mode 100644 index d98e36cf264..00000000000 --- a/features/doctrine/multiple_filter.feature +++ /dev/null @@ -1,48 +0,0 @@ -Feature: Multiple filters on collections - In order to retrieve large collections of filtered resources - As a client software developer - I need to retrieve collections filtered by multiple parameters - - @createSchema - Scenario: Get collection filtered by multiple parameters - Given there are 30 dummy objects with dummyDate and dummyBoolean true - And there are 20 dummy objects with dummyDate and dummyBoolean false - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28&dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=1&dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - diff --git a/features/doctrine/numeric_filter.feature b/features/doctrine/numeric_filter.feature deleted file mode 100644 index ec449ae0be7..00000000000 --- a/features/doctrine/numeric_filter.feature +++ /dev/null @@ -1,219 +0,0 @@ -Feature: Numeric filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections with numerical value - - @createSchema - Scenario: Get collection by dummyPrice=9.99 - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice=9.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/9$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice=9.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection by multiple dummyPrice - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[]=9.99&dummyPrice[]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/5$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 6, "maximum": 6}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5B%5D=9.99&dummyPrice%5B%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection by non-numeric dummyPrice=marty - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice=marty" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 20, "maximum": 20}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice=marty"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedInteger objects - When I send a "GET" request to "/converted_integers?name_converted[]=2&name_converted[]=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/(2|3)$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":2, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?name_converted%5B%5D=2&name_converted%5B%5D=3$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*name_converted,name_converted\\[\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"}, - {"pattern": "^order\\[name_converted\\]$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - diff --git a/features/doctrine/order_filter.feature b/features/doctrine/order_filter.feature deleted file mode 100644 index 4d3d5587b97..00000000000 --- a/features/doctrine/order_filter.feature +++ /dev/null @@ -1,824 +0,0 @@ -Feature: Order filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections ordered properties - - @createSchema - Scenario: Get collection ordered in ascending order on an integer property and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?order[id]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bid%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in descending order on an integer property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[id]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/30$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/29$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/28$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bid%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on a string property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[name]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/10$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/11$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in descending order on a string property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[name]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/9$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/8$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/7$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered collection on several property keep the order - # Adding 30 more data with the same name - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?order[name]=desc&order[id]=desc" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/39$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/9$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/38$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on an association and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects with relatedDummy - When I send a "GET" request to "/dummies?order[relatedDummy]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5BrelatedDummy%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on an embedded and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects with embeddedDummy - When I send a "GET" request to "/embedded_dummies?order[embeddedDummy]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?order%5BembeddedDummy%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered by default configured order on a embedded string property and on which order filter has been enabled in whitelist mode with default descending order - When I send a "GET" request to "/embedded_dummies?order[embeddedDummy.dummyName]" - Then the response status code should be 422 - - Scenario: Get collection ordered by a non valid properties and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[alias]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Balias%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[alias]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Balias%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[unknown]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bunknown%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[unknown]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bunknown%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection ordered in descending order on a related property - Given there are 2 dummy objects with relatedDummy - When I send a "GET" request to "/dummies?order[relatedDummy.name]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - } - ], - "additionalItems": false, - "maxItems": 2, - "minItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5BrelatedDummy.name%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 3 convertedInteger objects - When I send a "GET" request to "/converted_integers?order[name_converted]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/3$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":3, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/2$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":2, "maximum": 2} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/1$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":1, "maximum": 1} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - } - ], - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?order%5Bname_converted%5D=desc$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*order\\[name_converted\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^order\\[name_converted\\]$"}, - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - # See https://github.com/api-platform/core/pull/3673 - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 3 convertedInteger objects - When I send a "GET" request to "/converted_integers?order[]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/features/doctrine/range_filter.feature b/features/doctrine/range_filter.feature deleted file mode 100644 index 9a9ec12d074..00000000000 --- a/features/doctrine/range_filter.feature +++ /dev/null @@ -1,506 +0,0 @@ -Feature: Range filter on collections - In order to filter results from large collections of resources - As a client software developer - I need to filter collections by range - - @createSchema - Scenario: Get collection filtered by range (between) - Given there are 30 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[between]=12.99..15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/10$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/18$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/22$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/26$"}, - {"pattern": "^/dummies/27$"}, - {"pattern": "^/dummies/30$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 15, "maximum": 15}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=12.99..15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by range (between the same values) - Given there are 30 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[between]=12.99..12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/10$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 8, "maximum": 8}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=12.99..12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter by range (between) with invalid format - When I send a "GET" request to "/dummies?dummyPrice[between]=9.99..12.99..15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "pattern": "^/dummies/([1-9]|[12][0-9]|30)$" - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 30, "maximum": 30}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=9.99..12.99..15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (less than) - When I send a "GET" request to "/dummies?dummyPrice[lt]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/9$"}, - {"pattern": "^/dummies/13$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/21$"}, - {"pattern": "^/dummies/25$"}, - {"pattern": "^/dummies/29$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 8, "maximum": 8}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Blt%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (less than or equal) - When I send a "GET" request to "/dummies?dummyPrice[lte]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/9$"}, - {"pattern": "^/dummies/10$"}, - {"pattern": "^/dummies/13$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"}, - {"pattern": "^/dummies/21$"}, - {"pattern": "^/dummies/22$"}, - {"pattern": "^/dummies/25$"}, - {"pattern": "^/dummies/26$"}, - {"pattern": "^/dummies/29$"}, - {"pattern": "^/dummies/30$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 16, "maximum": 16}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Blte%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than) - When I send a "GET" request to "/dummies?dummyPrice[gt]=15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/8$"}, - {"pattern": "^/dummies/12$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/24$"}, - {"pattern": "^/dummies/28$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 7, "maximum": 7}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than or equal) - When I send a "GET" request to "/dummies?dummyPrice[gte]=15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/8$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/12$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/24$"}, - {"pattern": "^/dummies/27$"}, - {"pattern": "^/dummies/28$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 14, "maximum": 14}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgte%5D=15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than and less than) - When I send a "GET" request to "/dummies?dummyPrice[gt]=12.99&dummyPrice[lt]=19.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/27$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 7, "maximum": 7}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=12.99&dummyPrice%5Blt%5D=19.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities within an impossible range - When I send a "GET" request to "/dummies?dummyPrice[gt]=19.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:totalItems": {"type": "number", "maximum": 0}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=19.99$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedInteger objects - When I send a "GET" request to "/converted_integers?name_converted[lte]=2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/(1|2)$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":1, "maximum": 2} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?name_converted%5Blte%5D=2$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*name_converted\\[between\\],name_converted\\[gt\\],name_converted\\[gte\\],name_converted\\[lt\\],name_converted\\[lte\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"}, - {"pattern": "^order\\[name_converted\\]$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature deleted file mode 100644 index 50718f81963..00000000000 --- a/features/doctrine/search_filter.feature +++ /dev/null @@ -1,1066 +0,0 @@ -Feature: Search filter on collections - In order to get specific result from a large collections of resources - As a client software developer - I need to search for collections properties - - @createSchema - Scenario: Test ManyToMany with filter on join table - Given there is a RelatedDummy with 4 friends - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/related_dummies?relatedToDummyFriend.dummyFriend=/dummy_friends/4" - Then the response status code should be 200 - And the JSON node "_embedded.item" should have 1 element - And the JSON node "_embedded.item[0].id" should be equal to the number 1 - And the JSON node "_embedded.item[0]._links.relatedToDummyFriend" should have 4 elements - And the JSON node "_embedded.item[0]._embedded.relatedToDummyFriend" should have 4 elements - - @createSchema - Scenario: Test #944 - Given there is a DummyCar entity with related colors - When I send a "GET" request to "/dummy_cars?colors.prop=red" - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyCar", - "@id": "/dummy_cars", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy_cars/1", - "@type": "DummyCar", - "colors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "secondColors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "thirdColors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "uuid": [], - "carBrand": "DummyBrand" - } - ], - "hydra:totalItems": 1, - "hydra:view": { - "@id": "/dummy_cars?colors.prop=red", - "@type": "hydra:PartialCollectionView" - }, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummy_cars{?availableAt[before],availableAt[strictly_before],availableAt[after],availableAt[strictly_after],canSell,foobar[],foobargroups[],foobargroups_override[],colors.prop,colors,colors[],secondColors,secondColors[],thirdColors,thirdColors[],uuid,uuid[],name,brand,brand[]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "availableAt[before]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[strictly_before]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[after]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[strictly_after]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "canSell", - "property": "canSell", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobar[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobargroups[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobargroups_override[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors.prop", - "property": "colors.prop", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors", - "property": "colors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors[]", - "property": "colors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "secondColors", - "property": "secondColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "secondColors[]", - "property": "secondColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "thirdColors", - "property": "thirdColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "thirdColors[]", - "property": "thirdColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "uuid", - "property": "uuid", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "uuid[]", - "property": "uuid", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "brand", - "property": "brand", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "brand[]", - "property": "brand", - "required": false - } - ] - } - } - """ - - @createSchema - Scenario: Search collection by name (partial) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name=my" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name=my"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial) - Given there are 30 embedded dummy objects - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyName=my" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyName=my"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name[]=2&name[]=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/12$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name%5B%5D=2&name%5B%5D=3"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial case insensitive) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?dummy=somedummytest1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "dummy": { - "pattern": "^SomeDummyTest\\d{1,2}$" - } - } - } - } - } - } - """ - - @createSchema - Scenario: Search collection by alias (start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?alias=Ali" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?alias=Ali"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by alias (start multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description[]=Sma&description[]=Not" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description%5B%5D=Sma&description%5B%5D=Not"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @sqlite - @createSchema - Scenario: Search collection by description (word_start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description=smart" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description=smart"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - @sqlite - Scenario: Search collection by description (word_start multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description[]=smart&description[]=so" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description%5B%5D=smart&description%5B%5D=so"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - # note on Postgres compared to sqlite the LIKE clause is case sensitive - @postgres - @createSchema - Scenario: Search collection by description (word_start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description=smart" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/6$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description=smart"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search for entities within an impossible range - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name=MuYm" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name=MuYm$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @sqlite - @createSchema - Scenario: Search for entities with an existing collection route name - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?relatedDummies=dummy_cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array" - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummies=dummy_cars"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search related collection by name - Given there are 3 dummy objects having each 3 relatedDummies - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?relatedDummies.name=RelatedDummy1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "_embedded.item" should have 3 elements - And the JSON node "_embedded.item[0]._links.relatedDummies" should have 3 elements - And the JSON node "_embedded.item[1]._links.relatedDummies" should have 3 elements - And the JSON node "_embedded.item[2]._links.relatedDummies" should have 3 elements - - @createSchema - Scenario: Search by related collection id - Given there are 2 dummy objects having each 2 relatedDummies - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?relatedDummies=3" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "totalItems" should be equal to "1" - And the JSON node "_links.item" should have 1 element - And the JSON node "_links.item[0].href" should be equal to "/dummies/2" - - @createSchema - Scenario: Get collection by id equals 9.99 which is not possible - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?id=9.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=9.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection by id 10 - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?id=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/10$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=10"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection ordered by a non valid properties - When I send a "GET" request to "/dummies?unknown=0" - Given there are 30 dummy objects - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?unknown=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Search at third level - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.level=3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/31$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy.thirdLevel.level=3"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Search at fourth level - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.fourthLevel.level=4" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/31$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy.thirdLevel.fourthLevel.level=4"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection on a property using a name converted - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name_converted=Converted 3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/30$"} - ] - }, - "required": ["@id"] - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name_converted=Converted%203"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/dummies\\{\\?.*name_converted.*}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "additionalItems": true, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - - @createSchema - Scenario: Search collection on a property using a nested name converted - Given there are 30 convertedOwner objects with convertedRelated - When I send a "GET" request to "/converted_owners?name_converted.name_converted=Converted 3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedOwner$"}, - "@id": {"pattern": "^/converted_owners$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/converted_owners/3$"}, - {"pattern": "^/converted_owners/30$"} - ] - }, - "name_converted": { - "oneOf": [ - {"pattern": "^/converted_relateds/3$"}, - {"pattern": "^/converted_relateds/30$"} - ] - }, - "required": ["@id", "name_converted"] - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_owners\\?name_converted.name_converted=Converted%203"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_owners\\{\\?.*name_converted\\.name_converted.*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted\\.name_converted"}, - "property": {"pattern": "^name_converted\\.name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "additionalItems": true, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - @createSchema - Scenario: Search by date (#4128) - Given there are 3 dummydate objects with dummyDate - When I send a "GET" request to "/dummy_dates?dummyDate=2015-04-01" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Custom search filters can use Doctrine Expressions as join conditions - Given there is a dummy object with 3 relatedDummies and their thirdLevel - When I send a "GET" request to "/dummy_resource_with_custom_filter?custom=3" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Search on nested sub-entity that doesn't use "id" as its ORM identifier - Given there is a dummy entity with a sub entity with id "stringId" and name "someName" - When I send a "GET" request to "/dummy_with_subresource?subEntity=/dummy_subresource/stringId" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Filters can use UUIDs - Given there is a group object with uuid "61817181-0ecc-42fb-a6e7-d97f2ddcb344" and 2 users - And there is a group object with uuid "32510d53-f737-4e70-8d9d-58e292c871f8" and 1 users - When I send a "GET" request to "/issue5735/issue5735_users?groups[]=/issue5735/groups/61817181-0ecc-42fb-a6e7-d97f2ddcb344&groups[]=/issue5735/groups/32510d53-f737-4e70-8d9d-58e292c871f8" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 3 diff --git a/features/doctrine/separated_resource.feature b/features/doctrine/separated_resource.feature deleted file mode 100644 index 90ba193fd68..00000000000 --- a/features/doctrine/separated_resource.feature +++ /dev/null @@ -1,116 +0,0 @@ -Feature: Use state options to use an entity that is not a resource - In order to work with resources and a doctrine entity - As a client software developer - I need to retrieve a CRUD by specifying an entity class - - @!mongodb - @createSchema - Scenario: Get collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SeparatedEntity"}, - "@id": {"pattern": "^/separated_entities"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object" - } - }, - "hydra:totalItems": {"type":"number"}, - "hydra:view": { - "type": "object" - } - } - } - """ - - @!mongodb - @createSchema - Scenario: Get ordered collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities?order[value]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:member[0].value" should be equal to "5" - - @!mongodb - @createSchema - Scenario: Get item - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @!mongodb - @createSchema - Scenario: Get all EntityClassAndCustomProviderResources - Given there are 1 separated entities - When I send a "GET" request to "/entityClassAndCustomProviderResources" - Then the response status code should be 200 - - @!mongodb - @createSchema - Scenario: Get one EntityClassAndCustomProviderResource - Given there are 1 separated entities - When I send a "GET" request to "/entityClassAndCustomProviderResources/1" - Then the response status code should be 200 - - @mongodb - @createSchema - Scenario: Get collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SeparatedDocument"}, - "@id": {"pattern": "^/separated_documents"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object" - } - }, - "hydra:totalItems": {"type":"number"}, - "hydra:view": { - "type": "object" - } - } - } - """ - - @mongodb - @createSchema - Scenario: Get ordered collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents?order[value]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:member[0].value" should be equal to "5" - - @mongodb - @createSchema - Scenario: Get item - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature deleted file mode 100644 index f1e918b5242..00000000000 --- a/features/graphql/authorization.feature +++ /dev/null @@ -1,576 +0,0 @@ -Feature: Authorization checking - In order to use the GraphQL API - As a client software user - I need to be authorized to access a given resource. - - @createSchema - Scenario: An anonymous user tries to retrieve a secured item - Given there are 1 SecuredDummy objects - When I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - title - description - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummy" should be null - - Scenario: An anonymous user tries to retrieve a secured collection - Given there are 1 SecuredDummy objects - When I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummies" should be null - - Scenario: An admin can retrieve a secured collection - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummies" should exist - And the JSON node "data.securedDummies" should not be null - - Scenario: An anonymous user cannot retrieve a secured collection - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummies" should be null - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummies" should be null - - Scenario: An anonymous user tries to create a resource they are not allowed to - When I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { - securedDummy { - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Only admins can create a secured dummy." - And the JSON node "data.createSecuredDummy" should be null - - @createSchema - Scenario: An admin can access a secured collection relation - Given there are 1 SecuredDummy objects owned by admin with related dummies - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummies" should have 1 element - - Scenario: An admin can access a secured relation - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummy" should exist - And the JSON node "data.securedDummy.relatedDummy" should not be null - - @createSchema - Scenario: A user can't access a secured collection relation - Given there are 1 SecuredDummy objects owned by dunglas with related dummies - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummies" should be null - - Scenario: A user can't access a secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummy" should be null - - Scenario: A user can't access a secured relation resource directly - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - relatedSecuredDummy(id: "/related_secured_dummies/1") { - id - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.relatedSecuredDummy" should be null - - Scenario: A user can't access a secured relation resource collection directly - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - relatedSecuredDummies { - edges { - node { - id - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.relatedSecuredDummies" should be null - - Scenario: A user can access a secured collection relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedSecuredDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedSecuredDummies" should have 1 element - - Scenario: A user can access a secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedSecuredDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedSecuredDummy" should exist - And the JSON node "data.securedDummy.relatedSecuredDummy" should not be null - - Scenario: A user can access a non-secured collection relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - publicRelatedSecuredDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.publicRelatedSecuredDummies" should have 1 element - - Scenario: A user can access a non-secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - When I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - publicRelatedSecuredDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.publicRelatedSecuredDummy" should exist - And the JSON node "data.securedDummy.publicRelatedSecuredDummy" should not be null - - @createSchema - Scenario: An admin can create a secured resource - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.owner" should be equal to "someone" - - Scenario: An admin can create another secured resource - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.owner" should be equal to "dunglas" - - Scenario: An admin can create a secured resource with an owner-only property if they will be the owner - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "admin", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "it works"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should be equal to the string "it works" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - ownerOnlyProperty - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummies.edges[2].node.ownerOnlyProperty" should be equal to "it works" - - Scenario: An admin can't create a secured resource with an owner-only property if they won't be the owner - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "should not be set"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should exist - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should be null - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/4") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummy.ownerOnlyProperty" should be equal to "" - - Scenario: A user cannot retrieve an item they doesn't own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - owner - title - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummy" should be null - - Scenario: A user can retrieve an item they owns - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.owner" should be equal to the string "dunglas" - - Scenario: An admin can see a secured admin-only property on an object they don't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - adminOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.adminOnlyProperty" should exist - And the JSON node "data.securedDummy.adminOnlyProperty" should not be null - - Scenario: A user can't see a secured admin-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - adminOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.adminOnlyProperty" should be null - - Scenario: A user can see a secured owner-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.ownerOnlyProperty" should exist - And the JSON node "data.securedDummy.ownerOnlyProperty" should not be null - - Scenario: A user can update a secured owner-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/2", ownerOnlyProperty: "updated"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateSecuredDummy.securedDummy.ownerOnlyProperty" should be equal to the string "updated" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummy.ownerOnlyProperty" should be equal to the string "updated" - - Scenario: An admin can't see a secured owner-only property on an object they don't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.ownerOnlyProperty" should be null - - Scenario: A user can't assign to themself an item they doesn't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "kitten"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.updateSecuredDummy" should be null - - Scenario: A user can update an item they owns and transfer it - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/2", owner: "vincent"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateSecuredDummy.securedDummy.owner" should be equal to the string "vincent" diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature deleted file mode 100644 index afc2dc097ec..00000000000 --- a/features/graphql/collection.feature +++ /dev/null @@ -1,1109 +0,0 @@ -Feature: GraphQL collection support - - @createSchema - Scenario: Retrieve a collection through a GraphQL query - Given there are 4 dummy objects with relatedDummy and its thirdLevel - When I send the following GraphQL request: - """ - { - dummies { - ...dummyFields - } - } - fragment dummyFields on DummyCursorConnection { - edges { - node { - id - name - relatedDummy { - name - thirdLevel { - id - level - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[2].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummy.name" should be equal to "RelatedDummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummy.thirdLevel.level" should be equal to 3 - - @createSchema - Scenario: Retrieve an nonexistent collection through a GraphQL query - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - } - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - And the JSON node "data.dummies.pageInfo.endCursor" should be null - And the JSON node "data.dummies.pageInfo.startCursor" should be null - And the JSON node "data.dummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be false - - @createSchema - Scenario: Retrieve a collection with a nested collection through a GraphQL query - Given there are 4 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - relatedDummies { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[2].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy23" - - @createSchema - Scenario: Retrieve a collection with a nested collection (inverse side) through a GraphQL query - Given there is a video game with music groups - When I send the following GraphQL request: - """ - { - musicGroups { - edges { - node { - name - videoGames { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.musicGroups.edges[0].node.name" should be equal to "Sum 41" - And the JSON node "data.musicGroups.edges[0].node.videoGames.edges[0].node.name" should be equal to "Guitar Hero" - And the JSON node "data.musicGroups.edges[1].node.name" should be equal to "Franz Ferdinand" - And the JSON node "data.musicGroups.edges[1].node.videoGames.edges[0].node.name" should be equal to "Guitar Hero" - - @createSchema - Scenario: Retrieve a collection and an item through a GraphQL query - Given there are 3 dummy objects with dummyDate - And there are 2 dummy group objects - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - dummyDate - } - } - } - dummyGroup(id: "/dummy_groups/2") { - foo - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].node.dummyDate" should be equal to "2015-04-02" - And the JSON node "data.dummyGroup.foo" should be equal to "Foo #2" - - @createSchema - Scenario: Retrieve a specific number of items in a collection through a GraphQL query - Given there are 4 dummy objects - When I send the following GraphQL request: - """ - { - dummies(first: 2) { - edges { - node { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 2 elements - - @createSchema - Scenario: Retrieve a specific number of items in a nested collection through a GraphQL query - Given there are 2 dummy objects having each 5 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(first: 1) { - edges { - node { - name - relatedDummies(first: 2) { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - - @createSchema - Scenario: Paginate through collections through a GraphQL query - Given there are 4 dummy objects having each 4 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(first: 2) { - edges { - node { - name - relatedDummies(first: 2) { - edges { - node { - name - } - cursor - } - totalCount - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - totalCount - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.dummies.pageInfo.hasNextPage" should be true - And the JSON node "data.dummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].cursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.hasNextPage" should be true - And the JSON node "data.dummies.edges[1].node.relatedDummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy12" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MA==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "MQ==") { - edges { - node { - name - relatedDummies(first: 2, after: "MA==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "Mg==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy24" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "Mg==") { - edges { - node { - name - relatedDummies(first: 3, after: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #4" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy44" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].cursor" should be equal to "Mw==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "Mw==") { - edges { - node { - name - relatedDummies(first: 1, after: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - - @createSchema - Scenario: Paginate backwards through collections through a GraphQL query - Given there are 4 dummy objects having each 4 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(last: 2) { - edges { - node { - name - relatedDummies(last: 2) { - edges { - node { - name - } - cursor - } - totalCount - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - totalCount - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.dummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #4" - And the JSON node "data.dummies.edges[1].cursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.dummies.edges[1].node.relatedDummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy34" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "Mg==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "Mw==") { - edges { - node { - name - relatedDummies(last: 2, before: "Mg==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy13" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MA==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "MQ==") { - edges { - node { - name - relatedDummies(last: 3, before: "Mg==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.dummies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #1" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy21" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "MA==") { - edges { - node { - name - relatedDummies(last: 1, before: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - - @!mongodb - @createSchema - Scenario: Paginate through a collection through a GraphQL query with a partial pagination - Given there are 4 of these so many objects - When I send the following GraphQL request: - """ - { - soManies(first: 2) { - edges { - node { - content - } - cursor - } - totalCount - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.soManies.totalCount" should be equal to 0 - And the JSON node "data.soManies.edges[1].node.content" should be equal to "Many #2" - And the JSON node "data.soManies.edges[1].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "MQ==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.soManies.edges[0].node.content" should be equal to "Many #3" - And the JSON node "data.soManies.edges[0].cursor" should be equal to "Mg==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "Mg==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.edges" should have 1 element - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.soManies.edges[0].node.content" should be equal to "Many #4" - And the JSON node "data.soManies.edges[0].cursor" should be equal to "Mw==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "Mw==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.edges" should have 0 element - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "NA==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - - @createSchema - Scenario: Retrieve a collection with pagination disabled - Given there are 4 foo objects with fake names - When I send the following GraphQL request: - """ - { - foos { - id - name - bar - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.foos[3].id" should be equal to "/foos/4" - And the JSON node "data.foos[3].name" should be equal to "Separativeness" - And the JSON node "data.foos[3].bar" should be equal to "Sit" - - Scenario: Custom collection query - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionDummyCustomQueries { - edges { - node { - message - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionDummyCustomQueries": { - "edges": [ - { - "node": {"message": "Success!"} - }, - { - "node": {"message": "Success!"} - } - ] - } - } - } - """ - - @createSchema - Scenario: Custom collection query with read and serialize set to false - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionNoReadAndSerializeDummyCustomQueries { - edges { - node { - message - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionNoReadAndSerializeDummyCustomQueries": { - "edges": [] - } - } - } - """ - - @createSchema - Scenario: Custom collection query with custom arguments - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionCustomArgumentsDummyCustomQueries(customArgumentString: "A string") { - edges { - node { - message - customArgs - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionCustomArgumentsDummyCustomQueries": { - "edges": [ - { - "node": {"message": "Success!", "customArgs": {"customArgumentString": "A string"}} - }, - { - "node": {"message": "Success!", "customArgs": {"customArgumentString": "A string"}} - } - ] - } - } - } - """ - - @!mongodb - @createSchema - Scenario: Retrieve an item with composite primitive identifiers through a GraphQL query - Given there are composite primitive identifiers objects - When I send the following GraphQL request: - """ - { - compositePrimitiveItem(id: "/composite_primitive_items/name=Bar;year=2017") { - description - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.compositePrimitiveItem.description" should be equal to "This is bar." - - @!mongodb - @createSchema - Scenario: Retrieve an item with composite identifiers through a GraphQL query - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - { - compositeRelation(id: "/composite_relations/compositeItem=1;compositeLabel=1") { - value - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.compositeRelation.value" should be equal to "somefoobardummy" - - @createSchema - Scenario: Retrieve a collection using name converter - Given there are 4 dummy objects - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name_converted - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[1].node.name_converted" should be equal to "Converted 2" - - @createSchema - Scenario: Retrieve a collection with different serialization groups for item_query and collection_query - Given there are 3 dummy with different GraphQL serialization groups objects - When I send the following GraphQL request: - """ - { - dummyDifferentGraphQlSerializationGroups { - edges { - node { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.title" should not exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.title" should not exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.title" should not exist - - @createSchema - Scenario: Retrieve a paginated collection using page-based pagination - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1) { - collection { - id - name - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 3 elements - And the JSON node "data.fooDummies.collection[0].id" should exist - And the JSON node "data.fooDummies.collection[0].name" should exist - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[2].id" should exist - And the JSON node "data.fooDummies.collection[2].name" should exist - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - When I send the following GraphQL request: - """ - { - fooDummies(page: 3) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 0 elements - - @createSchema - Scenario: Retrieve a paginated collection using page-based pagination and client-defined limit - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[0].id" should exist - And the JSON node "data.fooDummies.collection[0].name" should exist - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - When I send the following GraphQL request: - """ - { - fooDummies(page: 2, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - When I send the following GraphQL request: - """ - { - fooDummies(page: 3, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 1 element - - @createSchema - Scenario: Retrieve paginated collections using mixed pagination - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 3 elements - And the JSON node "data.fooDummies.collection[2].id" should exist - And the JSON node "data.fooDummies.collection[2].name" should exist - And the JSON node "data.fooDummies.collection[2].soManies" should exist - And the JSON node "data.fooDummies.collection[2].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[2].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[2].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[1].soManies" should exist - And the JSON node "data.fooDummies.collection[1].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[1].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[1].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be false - - @createSchema - Scenario: Retrieve paginated collections using only hasNextPage - Given there are 4 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1, itemsPerPage: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[1].soManies" should exist - And the JSON node "data.fooDummies.collection[1].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[1].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[1].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be false diff --git a/features/graphql/docs.feature b/features/graphql/docs.feature deleted file mode 100644 index 7c54a7343f0..00000000000 --- a/features/graphql/docs.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Documentation support - In order to play with GraphQL - As a client software developer - I want to reach the GraphQL documentation - - Scenario: Retrieve the OpenAPI documentation - Given I add "Accept" header equal to "text/html" - And I send a "GET" request to "/graphql" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "text/html; charset=utf-8" diff --git a/features/graphql/filters.feature b/features/graphql/filters.feature deleted file mode 100644 index b5927c6598d..00000000000 --- a/features/graphql/filters.feature +++ /dev/null @@ -1,302 +0,0 @@ -Feature: Collections filtering - In order to retrieve subsets of collections - As an API consumer - I need to be able to set filters - - @createSchema - Scenario: Retrieve a collection filtered using the boolean filter - Given there is 1 dummy object with dummyBoolean true - And there is 1 dummy object with dummyBoolean false - When I send the following GraphQL request: - """ - { - dummies(dummyBoolean: false) { - edges { - node { - id - dummyBoolean - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.dummyBoolean" should be false - - @createSchema - Scenario: Retrieve a collection filtered using the exists filter - Given there are 3 dummy objects - And there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(exists: [{relatedDummy: true}]) { - edges { - node { - id - relatedDummy { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the JSON node "data.dummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummy" should have 1 element - - @createSchema - Scenario: Retrieve a collection filtered using the date filter - Given there are 3 dummy objects with dummyDate - When I send the following GraphQL request: - """ - { - dummies(dummyDate: [{after: "2015-04-02"}]) { - edges { - node { - id - dummyDate - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.dummyDate" should be equal to "2015-04-02" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter - Given there are 10 dummy objects - When I send the following GraphQL request: - """ - { - dummies(name: "#2") { - edges { - node { - id - name - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.id" should be equal to "/dummies/2" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter with an int - Given there are 4 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(name: "Dummy #1") { - totalCount - edges { - node { - name - relatedDummies(age: 31) { - totalCount - edges { - node { - id - name - age - } - } - } - } - } - } - } - """ - Then the JSON node "data.dummies.totalCount" should be equal to 1 - And the JSON node "data.dummies.edges[0].node.relatedDummies.totalCount" should be equal to 1 - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[0].node.age" should be equal to "31" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter and a name converter - Given there are 10 dummy objects - When I send the following GraphQL request: - """ - { - dummies(name_converted: "Converted 2") { - edges { - node { - id - name - name_converted - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.id" should be equal to "/dummies/2" - And the JSON node "data.dummies.edges[0].node.name_converted" should be equal to "Converted 2" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter and a name converter - Given there are 20 convertedOwner objects with convertedRelated - When I send the following GraphQL request: - """ - { - convertedOwners(name_converted__name_converted: "Converted 2") { - edges { - node { - id - name_converted { - name_converted - } - } - } - } - } - """ - Then the JSON node "data.convertedOwners.edges" should have 2 element - And the JSON node "data.convertedOwners.edges[0].node.id" should be equal to "/converted_owners/2" - And the JSON node "data.convertedOwners.edges[0].node.name_converted.name_converted" should be equal to "Converted 2" - And the JSON node "data.convertedOwners.edges[1].node.id" should be equal to "/converted_owners/20" - And the JSON node "data.convertedOwners.edges[1].node.name_converted.name_converted" should be equal to "Converted 20" - - @createSchema - Scenario: Retrieve a nested collection filtered using the search filter - Given there are 3 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - id - relatedDummies(name: "RelatedDummy13") { - edges { - node { - id - name - } - } - } - } - } - } - } - """ - Then the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 0 elements - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges" should have 0 elements - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges" should have 1 element - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy13" - - @createSchema - Scenario: Use a filter of a nested collection - Given there is a DummyCar entity with related colors - When I send the following GraphQL request: - """ - { - dummyCar(id: "/dummy_cars/1") { - id - colors(prop: "blue") { - edges { - node { - id - prop - } - } - } - } - } - """ - Then the JSON node "data.dummyCar.colors.edges" should have 1 element - And the JSON node "data.dummyCar.colors.edges[0].node.prop" should be equal to "blue" - - @createSchema - Scenario: Retrieve a collection filtered using the related search filter - Given there are 1 dummy objects having each 2 relatedDummies - And there are 1 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(relatedDummies__name: "RelatedDummy31") { - edges { - node { - id - } - } - } - } - """ - And the response status code should be 200 - And the JSON node "data.dummies.edges" should have 1 element - - @createSchema - Scenario: Retrieve a collection ordered using nested properties - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(order: [{relatedDummy__name: "DESC"}]) { - edges { - node { - name - relatedDummy { - id - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #1" - - @createSchema - Scenario: Retrieve a collection ordered correctly given the order of the argument - Given there are dummies with similar properties - When I send the following GraphQL request: - """ - { - dummies(order: [{description: "ASC"}, {name: "ASC"}]) { - edges { - node { - id - name - description - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "baz" - And the JSON node "data.dummies.edges[0].node.description" should be equal to "bar" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "foo" - And the JSON node "data.dummies.edges[1].node.description" should be equal to "bar" - - @createSchema - Scenario: Retrieve a collection filtered using the related search filter with two values and exact strategy - Given there are 3 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(relatedDummy__name_list: ["RelatedDummy #1", "RelatedDummy #2"]) { - edges { - node { - id - name - relatedDummy { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 2 element - And the JSON node "data.dummies.edges[0].node.relatedDummy.name" should be equal to "RelatedDummy #1" - And the JSON node "data.dummies.edges[1].node.relatedDummy.name" should be equal to "RelatedDummy #2" diff --git a/features/graphql/input_output.feature b/features/graphql/input_output.feature deleted file mode 100644 index aac22be3f3c..00000000000 --- a/features/graphql/input_output.feature +++ /dev/null @@ -1,202 +0,0 @@ -Feature: GraphQL DTO input and output - In order to use the GraphQL API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - - @createSchema - Scenario: Retrieve an Output with GraphQL - Given there is a RelatedDummy with 0 friends - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_dto_input_outputs" with body: - """ - { - "foo": "test", - "bar": 1, - "relatedDummies": ["/related_dummies/1"] - } - """ - Then the response status code should be 201 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "test", - "relatedDummies": [ - { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "name": "RelatedDummy with friends", - "dummyDate": null, - "thirdLevel": null, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": { - "@type": "EmbeddableDummy", - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "id": 1, - "symfony": "symfony", - "age": null - } - ] - } - """ - When I send the following GraphQL request: - """ - { - dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { - _id, id, baz, - relatedDummies { - edges { - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoInputOutput": { - "_id": 1, - "id": "/dummy_dto_input_outputs/1", - "baz": 1, - "relatedDummies": { - "edges": [ - { - "node": { - "name": "RelatedDummy with friends" - } - } - ] - } - } - } - } - """ - - Scenario: Create an item with custom input and output - When I send the following GraphQL request: - """ - mutation { - createDummyDtoInputOutput(input: {foo: "A foo", bar: 4, clientMutationId: "myId"}) { - dummyDtoInputOutput { - baz, - bat - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "createDummyDtoInputOutput": { - "dummyDtoInputOutput": { - "baz": 4, - "bat": "A foo" - }, - "clientMutationId": "myId" - } - } - } - """ - - Scenario: Create an item using custom inputClass & disabled outputClass - Given there are 2 dummyDtoNoOutput objects - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { - dummyDtoNoOutput { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be a superset of: - """ - { - "errors": [ - { - "message": "Cannot query field \"id\" on type \"DummyDtoNoOutput\".", - "locations": [ - { - "line": 4, - "column": 7 - } - ] - } - ] - } - """ - - Scenario: Cannot create an item with input fields using disabled inputClass - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "lorem" is not defined by type "?createDummyDtoNoInputInput"?\.$/' - And the JSON node "errors[1].message" should match '/^Field "ipsum" is not defined by type "?createDummyDtoNoInputInput"?\.$/' - - Scenario: Use messenger with GraphQL and an input where the handler gives a synchronous result - When I send the following GraphQL request: - """ - mutation { - createMessengerWithInput(input: {var: "test"}) { - messengerWithInput { id, name } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "createMessengerWithInput": { - "messengerWithInput": { - "id": "/messenger_with_inputs/1", - "name": "test" - } - } - } - } - """ diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature deleted file mode 100644 index 356fc67c577..00000000000 --- a/features/graphql/introspection.feature +++ /dev/null @@ -1,621 +0,0 @@ -Feature: GraphQL introspection support - - @createSchema - Scenario: Execute an empty GraphQL query - When I send a "GET" request to "/graphql" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 400 - And the JSON node "errors[0].message" should be equal to "GraphQL query is not valid." - - Scenario: Introspect the GraphQL schema - When I send the query to introspect the schema - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__schema.types" should exist - And the JSON node "data.__schema.queryType.name" should be equal to "Query" - And the JSON node "data.__schema.mutationType.name" should be equal to "Mutation" - - Scenario: Introspect types - When I send the following GraphQL request: - """ - { - type1: __type(name: "DummyProduct") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type2: __type(name: "DummyAggregateOfferCursorConnection") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type3: __type(name: "DummyAggregateOfferEdge") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.type1.description" should be equal to "Dummy Product." - And the JSON node "data.type1.fields" should contain: - """ - { - "name":"offers", - "type":{ - "name":"DummyAggregateOfferCursorConnection", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.type2.fields" should contain: - """ - { - "name":"edges", - "type":{ - "name":null, - "kind":"LIST", - "ofType":{ - "name":"DummyAggregateOfferEdge", - "kind":"OBJECT" - } - } - } - """ - And the JSON node "data.type3.fields" should contain: - """ - { - "name":"node", - "type":{ - "name":"DummyAggregateOffer", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.type3.fields" should contain: - """ - { - "name":"cursor", - "type":{ - "name":null, - "kind":"NON_NULL", - "ofType":{ - "name":"String", - "kind":"SCALAR" - } - } - } - """ - - Scenario: Introspect types with different serialization groups for item_query and collection_query - When I send the following GraphQL request: - """ - { - type1: __type(name: "DummyDifferentGraphQlSerializationGroupCollection") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type2: __type(name: "DummyDifferentGraphQlSerializationGroupItem") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.type1.description" should be equal to "Dummy with different serialization groups for item_query and collection_query." - And the JSON node "data.type1.fields[3].name" should not exist - And the JSON node "data.type2.fields[3].name" should be equal to "title" - - Scenario: Introspect deprecated queries - When I send the following GraphQL request: - """ - { - __type (name: "Query") { - name - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "deprecatedResources" is deprecated for the reason "This resource is deprecated" - - Scenario: Introspect deprecated mutations - When I send the following GraphQL request: - """ - { - __type (name: "Mutation") { - name - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deleteDeprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "updateDeprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "createDeprecatedResource" is deprecated for the reason "This resource is deprecated" - - Scenario: Introspect a deprecated field - When I send the following GraphQL request: - """ - { - __type(name: "DeprecatedResource") { - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deprecatedField" is deprecated for the reason "This field is deprecated" - - Scenario: Retrieve the Relay's node interface - When I send the following GraphQL request: - """ - { - __type(name: "Node") { - name - kind - fields { - name - type { - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "__type": { - "name": "Node", - "kind": "INTERFACE", - "fields": [ - { - "name": "id", - "type": { - "kind": "NON_NULL", - "ofType": { - "name": "ID", - "kind": "SCALAR" - } - } - } - ] - } - } - } - """ - - Scenario: Retrieve the Relay's node field - When I send the following GraphQL request: - """ - { - __schema { - queryType { - fields { - name - type { - name - kind - } - args { - name - type { - kind - ofType { - name - kind - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__schema.queryType.fields[0].name" should be equal to "node" - And the JSON node "data.__schema.queryType.fields[0].type.name" should be equal to "Node" - And the JSON node "data.__schema.queryType.fields[0].type.kind" should be equal to "INTERFACE" - And the JSON node "data.__schema.queryType.fields[0].args[0].name" should be equal to "id" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.kind" should be equal to "NON_NULL" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.ofType.name" should be equal to "ID" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.ofType.kind" should be equal to "SCALAR" - - Scenario: Introspect an Iterable type field - When I send the following GraphQL request: - """ - { - __type(name: "Dummy") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__type.fields" should contain: - """ - { - "name":"jsonData", - "type":{ - "name":"Iterable", - "kind":"SCALAR", - "ofType":null - } - } - """ - - Scenario: Retrieve entity - using serialization groups - fields - When I send the following GraphQL request: - """ - { - typeQuery: __type(name: "DummyGroup") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreateInput: __type(name: "createDummyGroupInput") { - description, - inputFields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayload: __type(name: "createDummyGroupPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayloadData: __type(name: "createDummyGroupPayloadData") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeQuery.fields" should have 2 elements - And the JSON node "data.typeQuery.fields[0].name" should be equal to "id" - And the JSON node "data.typeQuery.fields[1].name" should be equal to "foo" - And the JSON node "data.typeCreateInput.inputFields" should have 3 elements - And the JSON node "data.typeCreateInput.inputFields[0].name" should be equal to "bar" - And the JSON node "data.typeCreateInput.inputFields[1].name" should be equal to "baz" - And the JSON node "data.typeCreateInput.inputFields[2].name" should be equal to "clientMutationId" - And the JSON node "data.typeCreatePayload.fields" should have 2 elements - And the JSON node "data.typeCreatePayload.fields[0].name" should be equal to "dummyGroup" - And the JSON node "data.typeCreatePayload.fields[0].type.name" should be equal to "createDummyGroupPayloadData" - And the JSON node "data.typeCreatePayload.fields[1].name" should be equal to "clientMutationId" - And the JSON node "data.typeCreatePayloadData.fields" should have 2 elements - And the JSON node "data.typeCreatePayloadData.fields[0].name" should be equal to "id" - And the JSON node "data.typeCreatePayloadData.fields[1].name" should be equal to "bar" - - Scenario: Retrieve nested mutation payload data fields - When I send the following GraphQL request: - """ - { - typeCreatePayload: __type(name: "createDummyPropertyPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayloadData: __type(name: "createDummyPropertyPayloadData") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreateNestedPayload: __type(name: "createDummyGroupNestedPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeCreatePayload.fields" should be equal to: - """ - [ - { - "name":"dummyProperty", - "type":{ - "name":"createDummyPropertyPayloadData", - "kind":"OBJECT", - "ofType":null - } - }, - { - "name":"clientMutationId", - "type":{ - "name":"String", - "kind":"SCALAR", - "ofType":null - } - } - ] - """ - And the JSON node "data.typeCreatePayloadData.fields" should contain: - """ - { - "name":"group", - "type":{ - "name":"createDummyGroupNestedPayload", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.typeCreateNestedPayload.fields" should contain: - """ - { - "name":"id", - "type":{ - "name":null, - "kind":"NON_NULL", - "ofType":{ - "name":"ID", - "kind":"SCALAR" - } - } - } - """ - - Scenario: Retrieve a type name through a GraphQL query - Given there are 4 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummy: dummy(id: "/dummies/3") { - name - relatedDummy { - id - name - __typename - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.name" should be equal to "Dummy #3" - And the JSON node "data.dummy.relatedDummy.name" should be equal to "RelatedDummy #3" - And the JSON node "data.dummy.relatedDummy.__typename" should be equal to "RelatedDummy" - - Scenario: Introspect a type available only through relations - When I send the following GraphQL request: - """ - { - typeNotAvailable: __type(name: "VoDummyInspectionCursorConnection") { - description - } - typeOwner: __type(name: "VoDummyCar") { - description, - fields { - name - type { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeNotAvailable" should be null - And the JSON node "data.typeOwner.fields[1].type.name" should be equal to "VoDummyInspectionCursorConnection" - - Scenario: Introspect an enum - When I send the following GraphQL request: - """ - { - person: __type(name: "Person") { - name - fields { - name - type { - name - description - enumValues { - name - description - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.person.fields[1].type.name" should be equal to "GenderTypeEnum" - #And the JSON node "data.person.fields[1].type.description" should be equal to "An enumeration of genders." - And the JSON node "data.person.fields[1].type.enumValues[0].name" should be equal to "MALE" - #And the JSON node "data.person.fields[1].type.enumValues[0].description" should be equal to "The male gender." - And the JSON node "data.person.fields[1].type.enumValues[1].name" should be equal to "FEMALE" - And the JSON node "data.person.fields[1].type.enumValues[1].description" should be equal to "The female gender." - - Scenario: Introspect an enum resource - When I send the following GraphQL request: - """ - { - videoGame: __type(name: "VideoGame") { - name - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.videoGame.fields[3].type.ofType.name" should be equal to "GamePlayMode" diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature deleted file mode 100644 index 7df064279c1..00000000000 --- a/features/graphql/mutation.feature +++ /dev/null @@ -1,1071 +0,0 @@ -Feature: GraphQL mutation support - - @createSchema - Scenario: Introspect types - When I send the following GraphQL request: - """ - { - __type(name: "Mutation") { - fields { - name - description - type { - name - kind - } - args { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "__type" - ], - "properties": { - "__type": { - "type": "object", - "required": [ - "fields" - ], - "properties": { - "fields": { - "type": "array", - "minItems": 1, - "items": { - "oneOf": [ - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+$" - }, - "description": { - "pattern": "^Creates a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+$" - }, - "description": { - "pattern": "^Updates a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+$" - }, - "description": { - "pattern": "^Deletes a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+$" - }, - "description": { - "pattern": "^(?!Create|Update|Delete)[A-z0-9]+s a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - } - ] - } - } - } - } - } - } - } - } - """ - - Scenario: Create an item - When I send the following GraphQL request: - """ - mutation { - createFoo(input: {name: "A new one", bar: "new", clientMutationId: "myId"}) { - foo { - id - _id - __typename - name - bar - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createFoo.foo.id" should be equal to "/foos/1" - And the JSON node "data.createFoo.foo._id" should be equal to 1 - And the JSON node "data.createFoo.foo.__typename" should be equal to "Foo" - And the JSON node "data.createFoo.foo.name" should be equal to "A new one" - And the JSON node "data.createFoo.foo.bar" should be equal to "new" - And the JSON node "data.createFoo.clientMutationId" should be equal to "myId" - - Scenario: Create an item without a clientMutationId - When I send the following GraphQL request: - """ - mutation { - createFoo(input: {name: "Created without mutation id", bar: "works"}) { - foo { - id - name - bar - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createFoo.foo.id" should be equal to "/foos/2" - And the JSON node "data.createFoo.foo.name" should be equal to "Created without mutation id" - And the JSON node "data.createFoo.foo.bar" should be equal to "works" - - Scenario: Create an item with a relation to an existing resource - Given there are 1 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "A dummy", foo: [], relatedDummy: "/related_dummies/1", name_converted: "Converted" clientMutationId: "myId"}) { - dummy { - id - name - foo - relatedDummy { - name - __typename - } - name_converted - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummy.dummy.id" should be equal to "/dummies/2" - And the JSON node "data.createDummy.dummy.name" should be equal to "A dummy" - And the JSON node "data.createDummy.dummy.foo" should have 0 elements - And the JSON node "data.createDummy.dummy.relatedDummy.name" should be equal to "RelatedDummy #1" - And the JSON node "data.createDummy.dummy.relatedDummy.__typename" should be equal to "RelatedDummy" - And the JSON node "data.createDummy.dummy.name_converted" should be equal to "Converted" - And the JSON node "data.createDummy.clientMutationId" should be equal to "myId" - - Scenario: Create an item with an iterable field - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "A dummy", foo: [], jsonData: {bar:{baz:3,qux:[7.6,false,null]}}, arrayData: ["bar", "baz"], clientMutationId: "myId"}) { - dummy { - id - name - foo - jsonData - arrayData - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummy.dummy.id" should be equal to "/dummies/3" - And the JSON node "data.createDummy.dummy.name" should be equal to "A dummy" - And the JSON node "data.createDummy.dummy.foo" should have 0 elements - And the JSON node "data.createDummy.dummy.jsonData.bar.baz" should be equal to the number 3 - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[0]" should be equal to the number 7.6 - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[1]" should be false - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[2]" should be null - And the JSON node "data.createDummy.dummy.arrayData[1]" should be equal to baz - And the JSON node "data.createDummy.clientMutationId" should be equal to "myId" - - Scenario: Create an item with an enum - When I send the following GraphQL request: - """ - mutation { - createPerson(input: {name: "Mob", genderType: FEMALE}) { - person { - id - name - genderType - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createPerson.person.id" should be equal to "/people/1" - And the JSON node "data.createPerson.person.name" should be equal to "Mob" - And the JSON node "data.createPerson.person.genderType" should be equal to "FEMALE" - - @!mongodb - Scenario: Create an item with an enum collection - When I send the following GraphQL request: - """ - mutation { - createPerson(input: {name: "Harry", academicGrades: [BACHELOR, MASTER]}) { - person { - id - name - genderType - academicGrades - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createPerson.person.id" should be equal to "/people/2" - And the JSON node "data.createPerson.person.name" should be equal to "Harry" - And the JSON node "data.createPerson.person.genderType" should be equal to "MALE" - And the JSON node "data.createPerson.person.academicGrades" should have 2 elements - And the JSON node "data.createPerson.person.academicGrades[0]" should be equal to "BACHELOR" - And the JSON node "data.createPerson.person.academicGrades[1]" should be equal to "MASTER" - - Scenario: Create an item with an enum as a resource - When I send the following GraphQL request: - """ - { - gamePlayModes { - id - name - } - gamePlayMode(id: "/game_play_modes/SINGLE_PLAYER") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.gamePlayModes" should have 3 elements - And the JSON node "data.gamePlayModes[2].id" should be equal to "/game_play_modes/SINGLE_PLAYER" - And the JSON node "data.gamePlayModes[2].name" should be equal to "SINGLE_PLAYER" - And the JSON node "data.gamePlayMode.name" should be equal to "SINGLE_PLAYER" - When I send the following GraphQL request: - """ - mutation { - createVideoGame(input: {name: "Baten Kaitos", playMode: "/game_play_modes/SINGLE_PLAYER"}) { - videoGame { - id - name - playMode { - id - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createVideoGame.videoGame.id" should be equal to "/video_games/1" - And the JSON node "data.createVideoGame.videoGame.name" should be equal to "Baten Kaitos" - And the JSON node "data.createVideoGame.videoGame.playMode.id" should be equal to "/game_play_modes/SINGLE_PLAYER" - And the JSON node "data.createVideoGame.videoGame.playMode.name" should be equal to "SINGLE_PLAYER" - - Scenario: Delete an item through a mutation - When I send the following GraphQL request: - """ - mutation { - deleteFoo(input: {id: "/foos/1", clientMutationId: "anotherId"}) { - foo { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.deleteFoo.foo.id" should be equal to "/foos/1" - And the JSON node "data.deleteFoo.clientMutationId" should be equal to "anotherId" - - Scenario: Trigger an error trying to delete item of different resource - When I send the following GraphQL request: - """ - mutation { - deleteFoo(input: {id: "/dummies/1", clientMutationId: "myId"}) { - foo { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should be equal to 'Item "/dummies/1" did not match expected type "Foo".' - - @!mongodb - Scenario: Delete an item with composite identifiers through a mutation - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - mutation { - deleteCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=1", clientMutationId: "myId"}) { - compositeRelation { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.deleteCompositeRelation.compositeRelation.id" should be equal to "/composite_relations/compositeItem=1;compositeLabel=1" - And the JSON node "data.deleteCompositeRelation.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Modify an item through a mutation - Given there are 1 dummy objects having each 2 relatedDummies - When I send the following GraphQL request: - """ - mutation { - updateDummy(input: {id: "/dummies/1", description: "Modified description.", dummyDate: "2018-06-05T00:00:00+00:00", clientMutationId: "myId"}) { - dummy { - id - name - description - dummyDate - relatedDummies { - edges { - node { - name - } - } - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.id" should be equal to "/dummies/1" - And the JSON node "data.updateDummy.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.updateDummy.dummy.description" should be equal to "Modified description." - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2018-06-05" - And the JSON node "data.updateDummy.dummy.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy11" - And the JSON node "data.updateDummy.clientMutationId" should be equal to "myId" - - @createSchema - @!mongodb - Scenario: Modify an item with embedded object through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateFooDummy.fooDummy.name" should be equal to "modifiedName" - And the JSON node "data.updateFooDummy.fooDummy.embeddedFoo.dummyName" should be equal to "Embedded name" - And the JSON node "data.updateFooDummy.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Try to modify a non writable property through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", nonWritableProp: "written", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "nonWritableProp" is not defined by type "?updateFooDummyInput"?\.$/' - - @createSchema - @!mongodb - Scenario: Try to modify a non writable embedded property through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name", nonWritableProp: "written"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "nonWritableProp" is not defined by type "?FooEmbeddableNestedInput"?\.$/' - - @!mongodb - Scenario: Modify an item with composite identifiers through a mutation - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - mutation { - updateCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=2", value: "Modified value.", clientMutationId: "myId"}) { - compositeRelation { - id - value - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateCompositeRelation.compositeRelation.id" should be equal to "/composite_relations/compositeItem=1;compositeLabel=2" - And the JSON node "data.updateCompositeRelation.compositeRelation.value" should be equal to "Modified value." - And the JSON node "data.updateCompositeRelation.clientMutationId" should be equal to "myId" - - Scenario: Create an item with a custom UUID - When I send the following GraphQL request: - """ - mutation { - createWritableId(input: {_id: "c6b722fe-0331-48c4-a214-f81f9f1ca082", name: "Foo", clientMutationId: "m"}) { - writableId { - id - _id - name - } - clientMutationId - } - } - """ - Then the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createWritableId.writableId.id" should be equal to "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082" - And the JSON node "data.createWritableId.writableId._id" should be equal to "c6b722fe-0331-48c4-a214-f81f9f1ca082" - And the JSON node "data.createWritableId.writableId.name" should be equal to "Foo" - And the JSON node "data.createWritableId.clientMutationId" should be equal to "m" - - @!mongodb - Scenario: Update an item with a custom UUID - When I send the following GraphQL request: - """ - mutation { - updateWritableId(input: {id: "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082", _id: "f8a708b2-310f-416c-9aef-b1b5719dfa47", name: "Foo", clientMutationId: "m"}) { - writableId { - id - _id - name - } - clientMutationId - } - } - """ - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateWritableId.writableId.id" should be equal to "/writable_ids/f8a708b2-310f-416c-9aef-b1b5719dfa47" - And the JSON node "data.updateWritableId.writableId._id" should be equal to "f8a708b2-310f-416c-9aef-b1b5719dfa47" - And the JSON node "data.updateWritableId.writableId.name" should be equal to "Foo" - And the JSON node "data.updateWritableId.clientMutationId" should be equal to "m" - - Scenario: Use serialization groups - Given there are 1 dummy group objects - When I send the following GraphQL request: - """ - mutation { - createDummyGroup(input: {bar: "Bar", baz: "Baz", clientMutationId: "myId"}) { - dummyGroup { - id - bar - __typename - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummyGroup.dummyGroup.id" should be equal to "/dummy_groups/2" - And the JSON node "data.createDummyGroup.dummyGroup.bar" should be equal to "Bar" - And the JSON node "data.createDummyGroup.dummyGroup.__typename" should be equal to "createDummyGroupPayloadData" - And the JSON node "data.createDummyGroup.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Use serialization groups with relations - Given there is 1 dummy object with relatedDummy and its thirdLevel - And there is a RelatedDummy with 2 friends - And there is a dummy object with a fourth level relation - When I send the following GraphQL request: - """ - mutation { - updateRelatedDummy(input: { - id: "/related_dummies/2", - symfony: "laravel", - thirdLevel: { - fourthLevel: "/fourth_levels/1" - } - }) { - relatedDummy { - id - symfony - thirdLevel { - id - fourthLevel { - id - __typename - } - __typename - } - relatedToDummyFriend { - edges { - node { - name - } - } - __typename - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateRelatedDummy.relatedDummy.id" should be equal to "/related_dummies/2" - And the JSON node "data.updateRelatedDummy.relatedDummy.symfony" should be equal to "laravel" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.id" should be equal to "/third_levels/3" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.__typename" should be equal to "updateThirdLevelNestedPayload" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.fourthLevel.id" should be equal to "/fourth_levels/1" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.fourthLevel.__typename" should be equal to "updateFourthLevelNestedPayload" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.__typename" should be equal to "updateRelatedToDummyFriendNestedPayloadCursorConnection" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.edges[0].node.name" should be equal to "Relation-1" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.edges[1].node.name" should be equal to "Relation-2" - - Scenario: Trigger a validation error - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "", foo: [], clientMutationId: "myId"}) { - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to "422" - And the JSON node "errors[0].message" should be equal to "name: This value should not be blank." - And the JSON node "errors[0].extensions.violations" should exist - And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name" - And the JSON node "errors[0].extensions.violations[0].message" should be equal to "This value should not be blank." - - @createSchema - Scenario: Execute a custom mutation - Given there are 1 dummyCustomMutation objects - When I send the following GraphQL request: - """ - mutation { - sumDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumDummyCustomMutation.dummyCustomMutation.result" should be equal to "8" - - @createSchema - Scenario: Execute a not persisted custom mutation (resolver returns null) - Given there are 1 dummyCustomMutation objects - When I send the following GraphQL request: - """ - mutation { - sumNotPersistedDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumNotPersistedDummyCustomMutation.dummyCustomMutation" should be null - - Scenario: Execute a not persisted custom mutation (write set to false) with custom result - When I send the following GraphQL request: - """ - mutation { - sumNoWriteCustomResultDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumNoWriteCustomResultDummyCustomMutation.dummyCustomMutation.result" should be equal to "1234" - - Scenario: Execute a custom mutation with read, deserialize, validate and serialize set to false - When I send the following GraphQL request: - """ - mutation { - sumOnlyPersistDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumOnlyPersistDummyCustomMutation.dummyCustomMutation" should be null - - Scenario: Execute a custom mutation with custom arguments - When I send the following GraphQL request: - """ - mutation { - testCustomArgumentsDummyCustomMutation(input: {operandC: 18, clientMutationId: "myId"}) { - dummyCustomMutation { - result - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.testCustomArgumentsDummyCustomMutation.dummyCustomMutation.result" should be equal to "18" - And the JSON node "data.testCustomArgumentsDummyCustomMutation.clientMutationId" should be equal to "myId" - - Scenario: Uploading a file with a custom mutation - Given I have the following file for a GraphQL request: - | name | file | - | file | test.gif | - And I have the following GraphQL multipart request map: - """ - { - "file": ["variables.file"] - } - """ - When I send the following GraphQL multipart request operations: - """ - { - "query": "mutation($file: Upload!) { uploadMediaObject(input: {file: $file}) { mediaObject { id contentUrl } } }", - "variables": { - "file": null - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.uploadMediaObject.mediaObject.contentUrl" should be equal to "test.gif" - - Scenario: Uploading multiple files with a custom mutation - Given I have the following files for a GraphQL request: - | name | file | - | 0 | test.gif | - | 1 | test.gif | - | 2 | test.gif | - And I have the following GraphQL multipart request map: - """ - { - "0": ["variables.files.0"], - "1": ["variables.files.1"], - "2": ["variables.files.2"] - } - """ - When I send the following GraphQL multipart request operations: - """ - { - "query": "mutation($files: [Upload!]!) { uploadMultipleMediaObject(input: {files: $files}) { mediaObject { id contentUrl } } }", - "variables": { - "files": [ - null, - null, - null - ] - } - } - """ - Then the response status code should be 200 - And the JSON node "data.uploadMultipleMediaObject.mediaObject.contentUrl" should be equal to "test.gif" - - @!mongodb - Scenario: Delete an invalid item through a mutation - When I send the following GraphQL request: - """ - mutation { - deleteActivityLog(input: {id: "/activity_logs/1"}) { - activityLog { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.deleteActivityLog.activityLog" should exist - - @!mongodb - Scenario: Mutation should run before validation - When I send the following GraphQL request: - """ - mutation { - createActivityLog(input: {name: ""}) { - activityLog { - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createActivityLog.activityLog.name" should be equal to "hi" diff --git a/features/graphql/query.feature b/features/graphql/query.feature deleted file mode 100644 index 732540a65cb..00000000000 --- a/features/graphql/query.feature +++ /dev/null @@ -1,696 +0,0 @@ -Feature: GraphQL query support - - @createSchema - Scenario: Execute a basic GraphQL query - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - id - name - name_converted - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.id" should be equal to "/dummies/1" - And the JSON node "data.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.dummy.name_converted" should be equal to "Converted 1" - - @createSchema - Scenario: Retrieve an item with different relations to the same resource - Given there are 2 multiRelationsDummy objects having each 1 manyToOneRelation, 2 manyToManyRelations, 3 oneToManyRelations and 4 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 3 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - - @createSchema - Scenario: Retrieve embedded collections - Given there are 2 multiRelationsDummy objects having each 1 manyToOneRelation, 2 manyToManyRelations, 3 oneToManyRelations and 4 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - nestedCollection { - name - } - nestedPaginatedCollection { - edges{ - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation.name" should be equal to "RelatedManyToOneResolveDummy #2" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 3 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.nestedCollection[0].name" should be equal to "NestedDummy1" - And the JSON node "data.multiRelationsDummy.nestedCollection[1].name" should be equal to "NestedDummy2" - And the JSON node "data.multiRelationsDummy.nestedCollection[2].name" should be equal to "NestedDummy3" - And the JSON node "data.multiRelationsDummy.nestedCollection[3].name" should be equal to "NestedDummy4" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges" should have 4 element - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[0].node.name" should be equal to "NestedPaginatedDummy1" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[1].node.name" should be equal to "NestedPaginatedDummy2" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[2].node.name" should be equal to "NestedPaginatedDummy3" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[3].node.name" should be equal to "NestedPaginatedDummy4" - - @createSchema - Scenario: Retrieve an item with different relations (all unset) - Given there are 2 multiRelationsDummy objects having each 0 manyToOneRelation, 0 manyToManyRelations, 0 oneToManyRelations and 0 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - nestedCollection { - name - } - nestedPaginatedCollection { - edges{ - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation" should be null - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation" should be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 0 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 0 element - And the JSON node "data.multiRelationsDummy.nestedCollection" should have 0 element - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges" should have 0 element - - @createSchema @!mongodb - Scenario: Retrieve an item with child relation to the same resource - Given there are tree dummies - When I send the following GraphQL request: - """ - { - treeDummies { - edges { - node { - id - children { - totalCount - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.treeDummies.edges[0].node.id" should be equal to "/tree_dummies/1" - And the JSON node "data.treeDummies.edges[0].node.children.totalCount" should be equal to "1" - And the JSON node "data.treeDummies.edges[1].node.id" should be equal to "/tree_dummies/2" - And the JSON node "data.treeDummies.edges[1].node.children.totalCount" should be equal to "0" - - @createSchema - Scenario: Retrieve a Relay Node - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - node(id: "/dummies/1") { - id - ... on Dummy { - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.node.id" should be equal to "/dummies/1" - And the JSON node "data.node.name" should be equal to "Dummy #1" - - @createSchema - Scenario: Retrieve an item with an iterable field - Given there are 2 dummy objects with relatedDummy - Given there are 2 dummy objects with JSON and array data - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/3") { - id - name - jsonData - arrayData - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.id" should be equal to "/dummies/3" - And the JSON node "data.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.dummy.jsonData.foo" should have 2 elements - And the JSON node "data.dummy.jsonData.bar" should be equal to 5 - And the JSON node "data.dummy.arrayData[2]" should be equal to baz - - @createSchema - Scenario: Retrieve an item with an iterable null field - Given there are 2 dummy with null JSON objects - When I send the following GraphQL request: - """ - { - withJsonDummy(id: "/with_json_dummies/2") { - id - json - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.withJsonDummy.id" should be equal to "/with_json_dummies/2" - And the JSON node "data.withJsonDummy.json" should be null - - @createSchema - Scenario: Retrieve an item through a GraphQL query with variables - Given there are 2 dummy objects with relatedDummy - When I have the following GraphQL request: - """ - query DummyWithId($itemId: ID = "/dummies/1") { - dummyItem: dummy(id: $itemId) { - id - name - relatedDummy { - id - name - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/2" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyItem.id" should be equal to "/dummies/2" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #2" - And the JSON node "data.dummyItem.relatedDummy.id" should be equal to "/related_dummies/2" - And the JSON node "data.dummyItem.relatedDummy.name" should be equal to "RelatedDummy #2" - - Scenario: Run a specific operation through a GraphQL query - When I have the following GraphQL request: - """ - query DummyWithId1 { - dummyItem: dummy(id: "/dummies/1") { - name - } - } - query DummyWithId2 { - dummyItem: dummy(id: "/dummies/2") { - id - name - } - } - """ - And I send the GraphQL request with operationName "DummyWithId2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyItem.id" should be equal to "/dummies/2" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #2" - And I send the GraphQL request with operationName "DummyWithId1" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #1" - - Scenario: Use serialization groups - Given there are 1 dummy group objects - When I send the following GraphQL request: - """ - { - dummyGroup(id: "/dummy_groups/1") { - foo - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyGroup.foo" should be equal to "Foo #1" - - Scenario: Query a serialized name - Given there is a DummyCar entity with related colors - When I send the following GraphQL request: - """ - { - dummyCar(id: "/dummy_cars/1") { - carBrand - } - } - """ - Then the JSON node "data.dummyCar.carBrand" should be equal to "DummyBrand" - - Scenario: Fetch only the internal id - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - _id - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy._id" should be equal to "1" - - Scenario: Retrieve an nonexistent item through a GraphQL query - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/5") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy" should be null - - Scenario: Retrieve an nonexistent IRI through a GraphQL query - When I send the following GraphQL request: - """ - { - foo(id: "/foo/1") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL debug message should be equal to 'No route matches "/foo/1".' - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": {"type": "string"}, - "extensions": { - "type": "object", - "properties": { - "debugMessage": {"type": "string"}, - "file": {"type": "string"}, - "line": {"type": "integer"}, - "trace": { - "type": "array", - "items": { - "type": "object", - "properties": { - "file": {"type": "string"}, - "line": {"type": "integer"}, - "call": {"type": ["string", "null"]}, - "function": {"type": ["string", "null"]} - }, - "additionalProperties": false - }, - "minItems": 1 - } - } - }, - "locations": {"type": "array"}, - "path": {"type": "array"} - }, - "required": [ - "message", - "extensions", - "locations", - "path" - ] - }, - "minItems": 1, - "maxItems": 1 - } - } - } - """ - - Scenario: Use outputClass instead of resource class through a GraphQL query - Given there are 2 dummyDtoNoInput objects - When I send the following GraphQL request: - """ - { - dummyDtoNoInputs { - edges { - node { - baz - bat - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoNoInputs": { - "edges": [ - { - "node": { - "baz": 0.33, - "bat": "DummyDtoNoInput foo #1" - } - }, - { - "node": { - "baz": 0.67, - "bat": "DummyDtoNoInput foo #2" - } - } - ] - } - } - } - """ - - @createSchema - Scenario: Disable outputClass leads to an empty response through a GraphQL query - Given there are 2 dummyDtoNoOutput objects - When I send the following GraphQL request: - """ - { - dummyDtoNoInputs { - edges { - node { - baz - bat - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoNoInputs": { - "edges": [] - } - } - } - """ - - Scenario: Custom not retrieved item query - When I send the following GraphQL request: - """ - { - testNotRetrievedItemDummyCustomQuery { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testNotRetrievedItemDummyCustomQuery": { - "message": "Success (not retrieved)!" - } - } - } - """ - - Scenario: Custom item query with read and serialize set to false - When I send the following GraphQL request: - """ - { - testNoReadAndSerializeItemDummyCustomQuery(id: "/not_used") { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testNoReadAndSerializeItemDummyCustomQuery": null - } - } - """ - - Scenario: Custom item query - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testItemDummyCustomQuery(id: "/dummy_custom_queries/1") { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testItemDummyCustomQuery": { - "message": "Success!" - } - } - } - """ - - Scenario: Custom item query with custom arguments - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testItemCustomArgumentsDummyCustomQuery( - id: "/dummy_custom_queries/1", - customArgumentBool: true, - customArgumentInt: 3, - customArgumentString: "A string", - customArgumentFloat: 2.6, - customArgumentIntArray: [4], - customArgumentCustomType: "2019-05-24T00:00:00+00:00" - ) { - message - customArgs - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testItemCustomArgumentsDummyCustomQuery": { - "message": "Success!", - "customArgs": { - "id": "/dummy_custom_queries/1", - "customArgumentBool": true, - "customArgumentInt": 3, - "customArgumentString": "A string", - "customArgumentFloat": 2.6, - "customArgumentIntArray": [4], - "customArgumentCustomType": "2019-05-24T00:00:00+00:00" - } - } - } - } - """ - - @createSchema - Scenario: Retrieve an item with different serialization groups for item_query and collection_query - Given there are 1 dummy with different GraphQL serialization groups objects - When I send the following GraphQL request: - """ - { - dummyDifferentGraphQlSerializationGroup(id: "/dummy_different_graph_ql_serialization_groups/1") { - name - title - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyDifferentGraphQlSerializationGroup.name" should be equal to "Name #1" - And the JSON node "data.dummyDifferentGraphQlSerializationGroup.title" should be equal to "Title #1" - - Scenario: Call security after resolver - When I send the following GraphQL request: - """ - { - getSecurityAfterResolver(id: "/security_after_resolvers/1") { - name - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.getSecurityAfterResolver.name" should be equal to "test" - - - Scenario: Call security after resolver with 403 error (ensure /2 does not match securityAfterResolver) - When I send the following GraphQL request: - """" - { - getSecurityAfterResolver(id: "/security_after_resolvers/2") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.getSecurityAfterResolver.name" should not exist diff --git a/features/graphql/schema.feature b/features/graphql/schema.feature deleted file mode 100644 index 86a48cebd13..00000000000 --- a/features/graphql/schema.feature +++ /dev/null @@ -1,113 +0,0 @@ -Feature: GraphQL schema-related features - - @createSchema - Scenario: Export the GraphQL schema in SDL - When I run the command "api:graphql:export" - Then the command output should contain: - """ - ###Dummy Friend.### - type DummyFriend implements Node { - id: ID! - - ###The id### - _id: Int! - - ###The dummy name### - name: String! - } - """ - And the command output should contain: - """ - ###Cursor connection for DummyFriend.### - type DummyFriendCursorConnection { - edges: [DummyFriendEdge] - pageInfo: DummyFriendPageInfo! - totalCount: Int! - } - - ###Edge of DummyFriend.### - type DummyFriendEdge { - node: DummyFriend - cursor: String! - } - - ###Information about the current page.### - type DummyFriendPageInfo { - endCursor: String - startCursor: String - hasNextPage: Boolean! - hasPreviousPage: Boolean! - } - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - updateDummyFriend(input: updateDummyFriendInput!): updateDummyFriendPayload - - ###Deletes a DummyFriend.### - deleteDummyFriend(input: deleteDummyFriendInput!): deleteDummyFriendPayload - - ###Creates a DummyFriend.### - createDummyFriend(input: createDummyFriendInput!): createDummyFriendPayload - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - input updateDummyFriendInput { - id: ID! - - ###The dummy name### - name: String - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - type updateDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Deletes a DummyFriend.### - input deleteDummyFriendInput { - id: ID! - clientMutationId: String - } - - ###Deletes a DummyFriend.### - type deleteDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Creates a DummyFriend.### - input createDummyFriendInput { - ###The dummy name### - name: String! - clientMutationId: String - } - - ###Creates a DummyFriend.### - type createDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - "Updates a OptionalRequiredDummy." - input updateOptionalRequiredDummyInput { - id: ID! - thirdLevel: updateThirdLevelNestedInput - thirdLevelRequired: updateThirdLevelNestedInput! - - "Get relatedToDummyFriend." - relatedToDummyFriend: [updateRelatedToDummyFriendNestedInput] - clientMutationId: String - } - """ diff --git a/features/graphql/subscription.feature b/features/graphql/subscription.feature deleted file mode 100644 index 75863ec04bf..00000000000 --- a/features/graphql/subscription.feature +++ /dev/null @@ -1,224 +0,0 @@ -Feature: GraphQL subscription support - - @createSchema - Scenario: Introspect subscription type - When I send the following GraphQL request: - """ - { - __type(name: "Subscription") { - fields { - name - description - type { - name - kind - } - args { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "__type" - ], - "properties": { - "__type": { - "type": "object", - "required": [ - "fields" - ], - "properties": { - "fields": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Subscribe" - }, - "description": { - "pattern": "^Subscribes to the update event of a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+SubscriptionPayload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+SubscriptionInput$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - } - } - } - } - } - } - } - } - """ - - Scenario: Subscribe to updates - Given there are 2 dummy mercure objects - When I send the following GraphQL request: - """ - subscription { - updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { - dummyMercure { - id - name - relatedDummy { - name - } - } - mercureUrl - clientSubscriptionId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/1" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.name" should be equal to "Dummy Mercure #1" - And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks\?topic=http://example.com/subscriptions/[a-f0-9]+$@" - And the JSON node "data.updateDummyMercureSubscribe.clientSubscriptionId" should be equal to "myId" - - When I send the following GraphQL request: - """ - subscription { - updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { - dummyMercure { - id - } - mercureUrl - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/2" - And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks\?topic=http://example.com/subscriptions/[a-f0-9]+$@" - - Scenario: Receive Mercure updates with different payloads from subscriptions (legacy PUT in non-standard mode) - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mercures/1" with body: - """ - { - "name": "Dummy Mercure #1 updated" - } - """ - Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: - """ - { - "dummyMercure": { - "id": 1, - "name": "Dummy Mercure #1 updated", - "relatedDummy": { - "name": "RelatedDummy #1" - } - } - } - """ - - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mercures/2" with body: - """ - { - "name": "Dummy Mercure #2 updated" - } - """ - Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: - """ - { - "dummyMercure": { - "id": 2 - } - } - """ diff --git a/features/graphql/type.feature b/features/graphql/type.feature deleted file mode 100644 index 03a072785d5..00000000000 --- a/features/graphql/type.feature +++ /dev/null @@ -1,80 +0,0 @@ -Feature: GraphQL type support - - @createSchema - Scenario: Use a custom type for a field - Given there are 2 dummy objects with dummyDate - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - dummyDate - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.dummyDate" should be equal to "2015-04-01" - - Scenario: Use a custom type for an input field - When I send the following GraphQL request: - """ - mutation { - updateDummy(input: {id: "/dummies/1", dummyDate: "2019-05-24T00:00:00+00:00"}) { - dummy { - dummyDate - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2019-05-24" - - Scenario: Use a custom type for a query variable - When I have the following GraphQL request: - """ - mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { - updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { - dummy { - dummyDate - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/1", - "itemDate": "2017-11-14T00:00:00+00:00" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2017-11-14" - - Scenario: Use a custom type for a query variable and use a bad value - When I have the following GraphQL request: - """ - mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { - updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { - dummy { - dummyDate - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/1", - "itemDate": "bad date" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should contain 'Variable "$itemDate" got invalid value "bad date";' - And the JSON node "errors[0].message" should contain 'DateTime cannot represent non date value: "bad date"' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9f177ef37f1..aa1d500db50 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,7 +12,6 @@ - @@ -40,7 +39,6 @@ tests - features vendor .php-cs-fixer.dist.php diff --git a/src/Doctrine/Odm/Tests/AppKernel.php b/src/Doctrine/Odm/Tests/AppKernel.php index 773a4e31592..039813186e5 100644 --- a/src/Doctrine/Odm/Tests/AppKernel.php +++ b/src/Doctrine/Odm/Tests/AppKernel.php @@ -33,7 +33,6 @@ public function __construct(string $environment, bool $debug) { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; } diff --git a/src/Doctrine/Orm/Tests/AppKernel.php b/src/Doctrine/Orm/Tests/AppKernel.php index 66c5948a28c..3abb43d2880 100644 --- a/src/Doctrine/Orm/Tests/AppKernel.php +++ b/src/Doctrine/Orm/Tests/AppKernel.php @@ -33,7 +33,6 @@ public function __construct(string $environment, bool $debug) { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; } diff --git a/src/GraphQl/Test/GraphQlTestTrait.php b/src/GraphQl/Test/GraphQlTestTrait.php new file mode 100644 index 00000000000..925cacdfa11 --- /dev/null +++ b/src/GraphQl/Test/GraphQlTestTrait.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Test; + +use GraphQL\Type\Introspection; +use PHPUnit\Framework\Assert; +use PHPUnit\Framework\ExpectationFailedException; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * Helpers for functional GraphQL tests. + * + * Designed to be mixed into a class that exposes a static `createClient()` returning + * an HTTP client with a `request()` method (e.g. ApiPlatform\Symfony\Bundle\Test\ApiTestCase). + */ +trait GraphQlTestTrait +{ + /** + * @param array $variables + * @param array $headers + */ + protected function executeGraphQl(string $query, array $variables = [], ?string $operationName = null, array $headers = []): ResponseInterface + { + $payload = ['query' => $query]; + + if ($variables) { + $payload['variables'] = $variables; + } + + if (null !== $operationName) { + $payload['operationName'] = $operationName; + } + + $options = ['json' => $payload]; + + if ($headers) { + $options['headers'] = $headers; + } + + return static::createClient()->request('POST', '/graphql', $options); + } + + /** + * @param array $headers + */ + protected function introspectSchema(array $headers = []): ResponseInterface + { + return $this->executeGraphQl(Introspection::getIntrospectionQuery(), [], null, $headers); + } + + /** + * Send a `multipart/form-data` GraphQL request following the + * graphql-multipart-request-spec (https://github.com/jaydenseric/graphql-multipart-request-spec). + * + * @param array $files Map of file marker => absolute file path or UploadedFile + * @param array $headers + */ + protected function executeGraphQlMultipart(string $operations, string $map, array $files, array $headers = []): ResponseInterface + { + return static::createClient()->request('POST', '/graphql', [ + 'headers' => ['Content-Type' => 'multipart/form-data'] + $headers, + 'extra' => [ + 'parameters' => ['operations' => $operations, 'map' => $map], + 'files' => $files, + ], + ]); + } + + /** + * @param array{errors?: list} $data + */ + protected function assertGraphQlError(array $data, string $expectedMessage, int $index = 0): void + { + if (!isset($data['errors'][$index])) { + throw new ExpectationFailedException(\sprintf('No GraphQL error at index %d.', $index)); + } + + Assert::assertSame($expectedMessage, $data['errors'][$index]['message'] ?? null); + } + + /** + * Mirrors the Behat `the GraphQL debug message should be equal to` step: + * looks under `errors[$i].extensions.debugMessage` first, falls back to + * `errors[$i].debugMessage` for graphql-php < 15. + * + * @param array{errors?: list>} $data + */ + protected function assertGraphQlDebugMessage(array $data, string $expectedDebugMessage, int $index = 0): void + { + if (!isset($data['errors'][$index])) { + throw new ExpectationFailedException(\sprintf('No GraphQL error at index %d.', $index)); + } + + $error = $data['errors'][$index]; + $debug = $error['extensions']['debugMessage'] ?? $error['debugMessage'] ?? null; + + Assert::assertSame($expectedDebugMessage, $debug); + } + + /** + * Assert that a field returned by a `__type(name: ...) { fields { ... } }` query is + * flagged as deprecated with the given reason. + * + * @param array{data?: array{__type?: array{fields?: list>}}} $data + */ + protected function assertGraphQlFieldDeprecated(array $data, string $fieldName, string $reason): void + { + $fields = $data['data']['__type']['fields'] ?? null; + + if (!\is_array($fields)) { + throw new ExpectationFailedException('Expected response to contain "data.__type.fields".'); + } + + foreach ($fields as $field) { + if (($field['name'] ?? null) !== $fieldName) { + continue; + } + + if (true === ($field['isDeprecated'] ?? null) && $reason === ($field['deprecationReason'] ?? null)) { + Assert::assertTrue(true); + + return; + } + + throw new ExpectationFailedException(\sprintf('Field "%s" is not deprecated with reason "%s".', $fieldName, $reason)); + } + + throw new ExpectationFailedException(\sprintf('Field "%s" not found in "data.__type.fields".', $fieldName)); + } +} diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 89440051446..5b27935eed7 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -24,12 +24,6 @@ To run tests against MongoDB: ## Execution Guidelines -### Behat (Functional) - -* **Progress Format:** ALWAYS use \--format=progress. Without this, output verbosity increases execution time from \~10m to \~30m. -* **Tags:** Filter efficiently: vendor/bin/behat \--tags=@pagination \--format=progress -* **Debugging:** Only drop \--format=progress if you need to debug a *single* scenario using \-vvv. - ### PHPUnit * **Filtering:** Never run the full suite. Always filter by class or path. diff --git a/tests/Behat/CommandContext.php b/tests/Behat/CommandContext.php deleted file mode 100644 index 666f387410a..00000000000 --- a/tests/Behat/CommandContext.php +++ /dev/null @@ -1,106 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use GraphQL\Error\Error; -use PHPUnit\Framework\Assert; -use Symfony\Bundle\FrameworkBundle\Console\Application; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\HttpKernel\KernelInterface; - -/** - * Context for Symfony commands. - * - * @author Alan Poulain - */ -final class CommandContext implements Context -{ - private ?Application $application = null; - - private ?CommandTester $commandTester = null; - - public function __construct(private KernelInterface $kernel) - { - } - - /** - * @When I run the command :command - */ - public function iRunTheCommand(string $command): void - { - $command = $this->getApplication()->find($command); - - $this->getCommandTester($command)->execute([]); - } - - /** - * @When I run the command :command with options: - */ - public function iRunTheCommandWithOptions(string $command, TableNode $options): void - { - $command = $this->getApplication()->find($command); - - $this->getCommandTester($command)->execute($options->getRowsHash()); - } - - /** - * @Then the command output should be: - */ - public function theCommandOutputShouldBe(PyStringNode $expectedOutput): void - { - Assert::assertEquals($expectedOutput->getRaw(), $this->commandTester->getDisplay()); - } - - /** - * @Then the command output should contain: - */ - public function theCommandOutputShouldContain(PyStringNode $expectedOutput): void - { - // graphql-php < 15 - if (\defined(Error::class.'::CATEGORY_GRAPHQL')) { - $expectedOutput = str_replace('###', '"""', $expectedOutput->getRaw()); - } else { - $expectedOutput = str_replace('###', '"', $expectedOutput->getRaw()); - } - - Assert::assertStringContainsString($expectedOutput, $this->commandTester->getDisplay()); - } - - public function setKernel(KernelInterface $kernel): void - { - $this->kernel = $kernel; - } - - public function getApplication(): Application - { - if (null !== $this->application) { - return $this->application; - } - - $this->application = new Application($this->kernel); - - return $this->application; - } - - private function getCommandTester(Command $command): CommandTester - { - $this->commandTester = new CommandTester($command); - - return $this->commandTester; - } -} diff --git a/tests/Behat/CoverageContext.php b/tests/Behat/CoverageContext.php deleted file mode 100644 index ee5c171cd3d..00000000000 --- a/tests/Behat/CoverageContext.php +++ /dev/null @@ -1,92 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Driver\Selector; -use SebastianBergmann\CodeCoverage\Filter; -use SebastianBergmann\CodeCoverage\Report\PHP; -use Symfony\Component\Finder\Finder; - -/** - * Behat coverage. - * - * @author eliecharra - * @author Kévin Dunglas - * @copyright Adapted from https://gist.github.com/eliecharra/9c8b3ba57998b50e14a6 - */ -final class CoverageContext implements Context -{ - /** - * @var CodeCoverage - */ - private static $coverage; - - /** - * @BeforeSuite - */ - public static function setup(): void - { - $filter = new Filter(); - $finder = - (new Finder()) - ->in(__DIR__.'/../../src') - ->exclude([ - 'src/Core/Bridge/Symfony/Maker/Resources/skeleton', - 'tests/Fixtures/app/var', - 'docs/guides', - 'docs/var', - 'src/Doctrine/Orm/Tests/var', - 'src/Doctrine/Odm/Tests/var', - ]) - ->append([ - 'tests/Fixtures/app/console', - ]) - ->files() - ->name('*.php'); - - foreach ($finder as $file) { - $filter->includeFile((string) $file); - } - - self::$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); - } - - /** - * @AfterSuite - */ - public static function teardown(): void - { - $feature = getenv('FEATURE') ?: 'behat'; - (new PHP())->process(self::$coverage, __DIR__."/../../build/coverage/coverage-$feature.cov"); - } - - /** - * @BeforeScenario - */ - public function before(BeforeScenarioScope $scope): void - { - self::$coverage->start("{$scope->getFeature()->getTitle()}::{$scope->getScenario()->getTitle()}"); - } - - /** - * @AfterScenario - */ - public function after(): void - { - self::$coverage->stop(); - } -} diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php deleted file mode 100644 index 4639644beb4..00000000000 --- a/tests/Behat/DoctrineContext.php +++ /dev/null @@ -1,2707 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\AbsoluteUrlDummy as AbsoluteUrlDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\AbsoluteUrlRelationDummy as AbsoluteUrlRelationDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Address as AddressDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Answer as AnswerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Book as BookDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Comment as CommentDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeItem as CompositeItemDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeLabel as CompositeLabelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositePrimitiveItem as CompositePrimitiveItemDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeRelation as CompositeRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedBoolean as ConvertedBoolDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedDate as ConvertedDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedString as ConvertedStringDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Customer as CustomerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CustomMultipleIdentifierDummy as CustomMultipleIdentifierDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAggregateOffer as DummyAggregateOfferDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomMutation as DummyCustomMutationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoCustom as DummyDtoCustomDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyImmutableDate as DummyImmutableDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyOffer as DummyOfferDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyPassenger as DummyPassengerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyProduct as DummyProductDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyProperty as DummyPropertyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyTableInheritanceNotApiResourceChild as DummyTableInheritanceNotApiResourceChildDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyTravel as DummyTravelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FileConfigDummy as FileConfigDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooEmbeddable as FooEmbeddableDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriOnlyDummy as IriOnlyDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\LinkHandledDummy as LinkHandledDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNested as MultiRelationsNestedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNestedPaginated as MultiRelationsNestedPaginatedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsResolveDummy as MultiRelationsResolveDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PatchDummyRelation as PatchDummyRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Payment as PaymentDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PersonToPet as PersonToPetDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Pet as PetDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Product as ProductDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Program as ProgramDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnly as PropertyCollectionIriOnlyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnlyRelation as PropertyCollectionIriOnlyRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyUriTemplateOneToOneRelation as PropertyUriTemplateOneToOneRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Question as QuestionDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedLinkedDummy as RelatedLinkedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwnedDummy as RelatedOwnedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwningDummy as RelatedOwningDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedSecuredDummy as RelatedSecuredDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedToDummyFriend as RelatedToDummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelationEmbedder as RelationEmbedderDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as SecuredDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SeparatedEntity as SeparatedEntityDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Taxon as TaxonDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\UrlEncodedId as UrlEncodedIdDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\User as UserDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\VideoGame as VideoGameDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\WithJsonDummy as WithJsonDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlRelationDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Address; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Answer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Book; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Comment; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedString; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Customer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomMultipleIdentifierDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomMutation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMappedSubclass; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyOffer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPassenger; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EntityClassWithDateTime; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ExternalUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooEmbeddable; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Greeting; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InitializeInput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InternalUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriOnlyDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\Event; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\ItemLog; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Group; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6039\Issue6039EntityUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LinkHandledDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNested; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNestedPaginated; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsResolveDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Order; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PaginationEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PatchDummyRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Payment; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PersonToPet; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Pet; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Product; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Program; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedSecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationMultiple; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SeparatedEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Site; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TreeDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UrlEncodedId; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WithJsonDummy; -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ODM\MongoDB\DocumentManager; -use Doctrine\ODM\MongoDB\Query\Builder; -use Doctrine\ODM\MongoDB\SchemaManager; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Tools\SchemaTool; -use Doctrine\Persistence\ManagerRegistry; -use Doctrine\Persistence\ObjectManager; -use Ramsey\Uuid\Uuid; -use Symfony\Component\Uid\Uuid as SymfonyUuid; - -/** - * Defines application features from the specific context. - */ -final class DoctrineContext implements Context -{ - private ObjectManager $manager; - private ?SchemaTool $schemaTool; - private ?SchemaManager $schemaManager; - - /** - * Initializes context. - * - * Every scenario gets its own context instance. - * You can also pass arbitrary arguments to the - * context constructor through behat.yml. - */ - public function __construct(private readonly ManagerRegistry $doctrine, private readonly mixed $passwordHasher) - { - $this->manager = $doctrine->getManager(); - $this->schemaTool = $this->manager instanceof EntityManagerInterface ? new SchemaTool($this->manager) : null; - $this->schemaManager = $this->manager instanceof DocumentManager ? $this->manager->getSchemaManager() : null; - } - - /** - * @BeforeScenario @createSchema - */ - public function createDatabase(): void - { - /** @var ClassMetadata[] $classes */ - $classes = $this->manager->getMetadataFactory()->getAllMetadata(); - - if ($this->isOrm()) { - $this->schemaTool->dropSchema($classes); - $this->schemaTool->createSchema($classes); - } - - if ($this->isOdm()) { - $this->schemaManager->dropDatabases(); - } - - $this->doctrine->getManager()->clear(); - } - - /** - * @Then the DQL should be equal to: - */ - public function theDqlShouldBeEqualTo(PyStringNode $dql): void - { - /** @var EntityManager $manager */ - $manager = $this->doctrine->getManager(); - - $actualDql = $manager::$dql; - - $expectedDql = preg_replace('/\(\R */', '(', (string) $dql); - $expectedDql = preg_replace('/\R *\)/', ')', $expectedDql); - $expectedDql = preg_replace('/\R */', ' ', $expectedDql); - - if ($expectedDql !== $actualDql) { - throw new \RuntimeException("The DQL:\n'$actualDql' is not equal to:\n'$expectedDql'"); - } - } - - /** - * @Given there are :nb dummy objects - */ - public function thereAreDummyObjects(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDummy('SomeDummyTest'.$i); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->nameConverted = 'Converted '.$i; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb pagination entities - */ - public function thereArePaginationEntities(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $paginationEntity = new PaginationEntity(); - $this->manager->persist($paginationEntity); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb of these so many objects - */ - public function thereAreOfTheseSoManyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $soMany = $this->buildSoMany(); - $soMany->content = 'Many #'.$i; - - $this->manager->persist($soMany); - } - - $this->manager->flush(); - } - - /** - * @When some dummy table inheritance data but not api resource child are created - */ - public function someDummyTableInheritanceDataButNotApiResourceChildAreCreated(): void - { - $dummy = $this->buildDummyTableInheritanceNotApiResourceChild(); - $dummy->setName('Foobarbaz inheritance'); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are :nb foo objects with fake names - */ - public function thereAreFooObjectsWithFakeNames(int $nb): void - { - $names = ['Hawsepipe', 'Sthenelus', 'Ephesian', 'Separativeness', 'Balbo']; - $bars = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; - - for ($i = 0; $i < $nb; ++$i) { - $foo = $this->buildFoo(); - $foo->setName($names[$i]); - $foo->setBar($bars[$i]); - - $this->manager->persist($foo); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb fooDummy objects with fake names - */ - public function thereAreFooDummyObjectsWithFakeNames(int $nb, $embedd = false): void - { - $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; - $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; - - for ($i = 0; $i < $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName($dummies[$i]); - - $foo = $this->buildFooDummy(); - $foo->setName($names[$i]); - if ($embedd) { - $embeddedFoo = $this->buildFooEmbeddable(); - $embeddedFoo->setDummyName('embedded'.$names[$i]); - $foo->setEmbeddedFoo($embeddedFoo); - } - $foo->setDummy($dummy); - for ($j = 0; $j < 3; ++$j) { - $soMany = $this->buildSoMany(); - $soMany->content = "So many $j"; - $soMany->fooDummy = $foo; - $foo->soManies->add($soMany); - } - - $this->manager->persist($foo); - } - - $this->manager->flush(); - } - - /** - * @Given there is a fooDummy objects with fake names and embeddable - */ - public function thereAreFooDummyObjectsWithFakeNamesAndEmbeddable(): void - { - $this->thereAreFooDummyObjectsWithFakeNames(1, true); - } - - /** - * @Given there are :nb dummy group objects - */ - public function thereAreDummyGroupObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz', 'qux'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $this->manager->persist($dummyGroup); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects - */ - public function thereAreDummyPropertyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - $dummyProperty->nameConverted = "NameConverted #$i"; - - $dummyProperty->group = $dummyGroup; - - $this->manager->persist($dummyGroup); - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with a shared group - */ - public function thereAreDummyPropertyObjectsWithASharedGroup(int $nb): void - { - $dummyGroup = $this->buildDummyGroup(); - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #shared'; - } - $this->manager->persist($dummyGroup); - - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = ucfirst($property).' #'.$i; - } - - $dummyProperty->group = $dummyGroup; - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with different number of related groups - */ - public function thereAreDummyPropertyObjectsWithADifferentNumberRelatedGroups(int $nb): void - { - $dummyGroups = []; - for ($i = 1; $i <= $nb; ++$i) { - $dummyGroup = $this->buildDummyGroup(); - $dummyProperty = $this->buildDummyProperty(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $this->manager->persist($dummyGroup); - $dummyGroups[$i] = $dummyGroup; - - for ($j = 1; $j <= $i; ++$j) { - $dummyProperty->groups[] = $dummyGroups[$j]; - } - - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with :nb2 groups - */ - public function thereAreDummyPropertyObjectsWithGroups(int $nb, int $nb2): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $dummyProperty->group = $dummyGroup; - - $this->manager->persist($dummyGroup); - for ($j = 1; $j <= $nb2; ++$j) { - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #'.$i.$j; - } - - $dummyProperty->groups[] = $dummyGroup; - $this->manager->persist($dummyGroup); - } - - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects - */ - public function thereAreEmbeddedDummyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with relatedDummy - */ - public function thereAreDummyObjectsWithRelatedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->nameConverted = "Converted $i"; - $dummy->setRelatedDummy($relatedDummy); - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are dummies with similar properties - */ - public function thereAreDummiesWithSimilarProperties(): void - { - $dummy1 = $this->buildDummy(); - $dummy1->setName('foo'); - $dummy1->setDescription('bar'); - - $dummy2 = $this->buildDummy(); - $dummy2->setName('baz'); - $dummy2->setDescription('qux'); - - $dummy3 = $this->buildDummy(); - $dummy3->setName('foo'); - $dummy3->setDescription('qux'); - - $dummy4 = $this->buildDummy(); - $dummy4->setName('baz'); - $dummy4->setDescription('bar'); - - $this->manager->persist($dummy1); - $this->manager->persist($dummy2); - $this->manager->persist($dummy3); - $this->manager->persist($dummy4); - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyDtoNoInput objects - */ - public function thereAreDummyDtoNoInputObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDto = $this->buildDummyDtoNoInput(); - $dummyDto->lorem = 'DummyDtoNoInput foo #'.$i; - $dummyDto->ipsum = round($i / 3, 2); - - $this->manager->persist($dummyDto); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyDtoNoOutput objects - */ - public function thereAreDummyDtoNoOutputObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDto = $this->buildDummyDtoNoOutput(); - $dummyDto->lorem = 'DummyDtoNoOutput foo #'.$i; - $dummyDto->ipsum = (string) ($i / 3); - - $this->manager->persist($dummyDto); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyCustomQuery objects - */ - public function thereAreDummyCustomQueryObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyCustomQuery = $this->buildDummyCustomQuery(); - - $this->manager->persist($dummyCustomQuery); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyCustomMutation objects - */ - public function thereAreDummyCustomMutationObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $customMutationDummy = $this->buildDummyCustomMutation(); - $customMutationDummy->setOperandA(3); - - $this->manager->persist($customMutationDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with JSON and array data - */ - public function thereAreDummyObjectsWithJsonData(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setJsonData(['foo' => ['bar', 'baz'], 'bar' => 5]); - $dummy->setArrayData(['foo', 'bar', 'baz']); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy with null JSON objects - */ - public function thereAreDummyWithNullJsonObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildWithJsonDummy(); - $dummy->json = null; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with relatedDummy and its thirdLevel - * @Given there is :nb dummy object with relatedDummy and its thirdLevel - */ - public function thereAreDummyObjectsWithRelatedDummyAndItsThirdLevel(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $thirdLevel = $this->buildThirdLevel(); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setRelatedDummy($relatedDummy); - - $this->manager->persist($thirdLevel); - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with :nb relatedDummies and their thirdLevel - */ - public function thereIsADummyObjectWithRelatedDummiesAndTheirThirdLevel(int $nb): void - { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - - for ($i = 1; $i <= $nb; ++$i) { - $thirdLevel = $this->buildThirdLevel(); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy->addRelatedDummy($relatedDummy); - - $this->manager->persist($thirdLevel); - $this->manager->persist($relatedDummy); - } - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with :nb relatedDummies with same thirdLevel - */ - public function thereIsADummyObjectWithRelatedDummiesWithSameThirdLevel(int $nb): void - { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - $thirdLevel = $this->buildThirdLevel(); - - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy->addRelatedDummy($relatedDummy); - - $this->manager->persist($relatedDummy); - } - $this->manager->persist($thirdLevel); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with embeddedDummy - */ - public function thereAreDummyObjectsWithEmbeddedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('EmbeddedDummy #'.$i); - - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects having each :nbrelated relatedDummies - */ - public function thereAreDummyObjectsWithRelatedDummies(int $nb, int $nbrelated): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - - for ($j = 1; $j <= $nbrelated; ++$j) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy'.$j.$i); - $relatedDummy->setAge((int) ($j.$i)); - $this->manager->persist($relatedDummy); - - $dummy->addRelatedDummy($relatedDummy); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb multiRelationsDummy objects having each :nbmtor manyToOneRelation, :nbmtmr manyToManyRelations, :nbotmr oneToManyRelations and :nber embeddedRelations - */ - public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsOneToManyRelationsAndEmbeddedRelations(int $nb, int $nbmtor, int $nbmtmr, int $nbotmr, int $nber): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildMultiRelationsRelatedDummy(); - $relatedDummy->name = 'RelatedManyToOneDummy #'.$i; - - $resolveDummy = $this->buildMultiRelationsResolveDummy(); - $resolveDummy->name = 'RelatedManyToOneResolveDummy #'.$i; - - $dummy = $this->buildMultiRelationsDummy(); - $dummy->name = 'Dummy #'.$i; - - if ($nbmtor) { - $dummy->setManyToOneRelation($relatedDummy); - $dummy->setManyToOneResolveRelation($resolveDummy); - } - - for ($j = 1; $j <= $nbmtmr; ++$j) { - $manyToManyItem = $this->buildMultiRelationsRelatedDummy(); - $manyToManyItem->name = 'RelatedManyToManyDummy'.$j.$i; - $this->manager->persist($manyToManyItem); - - $dummy->addManyToManyRelation($manyToManyItem); - } - - for ($j = 1; $j <= $nbotmr; ++$j) { - $oneToManyItem = $this->buildMultiRelationsRelatedDummy(); - $oneToManyItem->name = 'RelatedOneToManyDummy'.$j.$i; - $oneToManyItem->setOneToManyRelation($dummy); - $this->manager->persist($oneToManyItem); - - $dummy->addOneToManyRelation($oneToManyItem); - } - - $nested = new ArrayCollection(); - for ($j = 1; $j <= $nber; ++$j) { - $embeddedItem = $this->buildMultiRelationsNested(); - $embeddedItem->name = 'NestedDummy'.$j; - $nested->add($embeddedItem); - } - $dummy->setNestedCollection($nested); - - $nestedPaginated = new ArrayCollection(); - for ($j = 1; $j <= $nber; ++$j) { - $embeddedItem = $this->buildMultiRelationsNestedPaginated(); - $embeddedItem->name = 'NestedPaginatedDummy'.$j; - $nestedPaginated->add($embeddedItem); - } - $dummy->setNestedPaginatedCollection($nestedPaginated); - - $this->manager->persist($relatedDummy); - $this->manager->persist($resolveDummy); - $this->manager->persist($dummy); - } - $this->manager->flush(); - } - - /** - * @Given there are tree dummies - */ - public function thereAreTreeDummies(): void - { - $parentDummy = new TreeDummy(); - $this->manager->persist($parentDummy); - - $childDummy = new TreeDummy(); - $childDummy->setParent($parentDummy); - - $this->manager->persist($childDummy); - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate - * @Given there is :nb dummy object with dummyDate - */ - public function thereAreDummyObjectsWithDummyDate(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate and dummyBoolean :bool - */ - public function thereAreDummyObjectsWithDummyDateAndDummyBoolean(int $nb, string $bool): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyBoolean($bool); - - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate and relatedDummy - */ - public function thereAreDummyObjectsWithDummyDateAndRelatedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setDummyDate($date); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setRelatedDummy($relatedDummy); - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with dummyDate and embeddedDummy - */ - public function thereAreDummyObjectsWithDummyDateAndEmbeddedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embeddable #'.$i); - $embeddableDummy->setDummyDate($date); - - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedDate objects - */ - public function thereAreconvertedDateObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedDate = $this->buildConvertedDate(); - $convertedDate->nameConverted = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $this->manager->persist($convertedDate); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedString objects - */ - public function thereAreconvertedStringObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedString = $this->buildConvertedString(); - $convertedString->nameConverted = ($i % 2) ? "name#$i" : null; - - $this->manager->persist($convertedString); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedBoolean objects - */ - public function thereAreconvertedBooleanObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedBoolean = $this->buildConvertedBoolean(); - $convertedBoolean->nameConverted = (bool) ($i % 2); - - $this->manager->persist($convertedBoolean); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedInteger objects - */ - public function thereAreconvertedIntegerObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedInteger = $this->buildConvertedInteger(); - $convertedInteger->nameConverted = $i; - - $this->manager->persist($convertedInteger); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyPrice - */ - public function thereAreDummyObjectsWithDummyPrice(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - $prices = ['9.99', '12.99', '15.99', '19.99']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyPrice($prices[($i - 1) % 4]); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyBoolean :bool - * @Given there is :nb dummy object with dummyBoolean :bool - */ - public function thereAreDummyObjectsWithDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyBoolean($bool); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with embeddedDummy.dummyBoolean :bool - */ - public function thereAreDummyObjectsWithEmbeddedDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Embedded Dummy #'.$i); - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embedded Dummy #'.$i); - $embeddableDummy->setDummyBoolean($bool); - $dummy->setEmbeddedDummy($embeddableDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean :bool - */ - public function thereAreDummyObjectsWithRelationEmbeddedDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Embedded Dummy #'.$i); - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embedded Dummy #'.$i); - $embeddableDummy->setDummyBoolean($bool); - - $relationDummy = $this->buildRelatedDummy(); - $relationDummy->setEmbeddedDummy($embeddableDummy); - - $dummy->setRelatedDummy($relationDummy); - - $this->manager->persist($relationDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb SecuredDummy objects - */ - public function thereAreSecuredDummyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $securedDummy = $this->buildSecuredDummy(); - $securedDummy->setTitle("#$i"); - $securedDummy->setDescription("Hello #$i"); - $securedDummy->setOwner('notexist'); - - $this->manager->persist($securedDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb SecuredDummy objects owned by :ownedby with related dummies - */ - public function thereAreSecuredDummyObjectsOwnedByWithRelatedDummies(int $nb, string $ownedby): void - { - for ($i = 1; $i <= $nb; ++$i) { - $securedDummy = $this->buildSecuredDummy(); - $securedDummy->setTitle("#$i"); - $securedDummy->setDescription("Hello #$i"); - $securedDummy->setOwner($ownedby); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy'); - $this->manager->persist($relatedDummy); - - $relatedSecuredDummy = $this->buildRelatedSecureDummy(); - $this->manager->persist($relatedSecuredDummy); - - $publicRelatedSecuredDummy = $this->buildRelatedSecureDummy(); - $this->manager->persist($publicRelatedSecuredDummy); - - $relatedLinkedDummy = $this->buildRelatedLinkedDummy(); - $this->manager->persist($relatedLinkedDummy); - - $securedDummy->addRelatedDummy($relatedDummy); - $securedDummy->setRelatedDummy($relatedDummy); - $securedDummy->addRelatedSecuredDummy($relatedSecuredDummy); - $securedDummy->setRelatedSecuredDummy($relatedSecuredDummy); - $securedDummy->addPublicRelatedSecuredDummy($publicRelatedSecuredDummy); - $securedDummy->setPublicRelatedSecuredDummy($publicRelatedSecuredDummy); - $relatedLinkedDummy->setSecuredDummy($securedDummy); - - $this->manager->persist($securedDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a RelationEmbedder object - */ - public function thereIsARelationEmbedderObject(): void - { - $relationEmbedder = $this->buildRelationEmbedder(); - - $this->manager->persist($relationEmbedder); - $this->manager->flush(); - } - - /** - * @Given there is a Dummy Object mapped by UUID - */ - public function thereIsADummyObjectMappedByUUID(): void - { - $dummy = new UuidIdentifierDummy(); - $dummy->setName('My Dummy'); - $dummy->setUuid('41B29566-144B-11E6-A148-3E1D05DEFE78'); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are Composite identifier objects - */ - public function thereIsACompositeIdentifierObject(): void - { - $item = $this->buildCompositeItem(); - $item->setField1('foobar'); - $this->manager->persist($item); - $this->manager->flush(); - - for ($i = 0; $i < 4; ++$i) { - $label = $this->buildCompositeLabel(); - $label->setValue('foo-'.$i); - - $rel = $this->buildCompositeRelation(); - $rel->setCompositeLabel($label); - $rel->setCompositeItem($item); - $rel->setValue('somefoobardummy'); - - $this->manager->persist($label); - // since doctrine 2.6 we need existing identifiers on relations - $this->manager->flush(); - $this->manager->persist($rel); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there are composite primitive identifiers objects - */ - public function thereAreCompositePrimitiveIdentifiersObjects(): void - { - $foo = $this->buildCompositePrimitiveItem('Foo', 2016); - $foo->setDescription('This is foo.'); - $this->manager->persist($foo); - - $bar = $this->buildCompositePrimitiveItem('Bar', 2017); - $bar->setDescription('This is bar.'); - $this->manager->persist($bar); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a FileConfigDummy object - */ - public function thereIsAFileConfigDummyObject(): void - { - $fileConfigDummy = $this->buildFileConfigDummy(); - $fileConfigDummy->setName('ConfigDummy'); - $fileConfigDummy->setFoo('Foo'); - - $this->manager->persist($fileConfigDummy); - $this->manager->flush(); - } - - /** - * @Given there is a DummyCar entity with related colors - */ - public function thereIsAFooEntityWithRelatedBars(): void - { - $foo = $this->buildDummyCar(); - $foo->setName('mustli'); - $foo->setCanSell(true); - $foo->setAvailableAt(new \DateTime()); - $this->manager->persist($foo); - $this->manager->flush(); - - if (\is_object($foo->getId())) { - $this->manager->persist($foo->getId()); - $this->manager->flush(); - } - - $bar1 = $this->buildDummyCarColor(); - $bar1->setProp('red'); - $bar1->setCar($foo); - $this->manager->persist($bar1); - $this->manager->flush(); - - $bar2 = $this->buildDummyCarColor(); - $bar2->setProp('blue'); - $bar2->setCar($foo); - $this->manager->persist($bar2); - $this->manager->flush(); - - $foo->setColors(new ArrayCollection([$bar1, $bar2])); - $this->manager->persist($foo); - $this->manager->flush(); - } - - /** - * @Given there is a dummy travel - */ - public function thereIsADummyTravel(): void - { - $car = $this->buildDummyCar(); - $car->setName('model x'); - $car->setCanSell(true); - $car->setAvailableAt(new \DateTime()); - $this->manager->persist($car); - - $passenger = $this->buildDummyPassenger(); - $passenger->nickname = 'Tom'; - $this->manager->persist($passenger); - - $travel = $this->buildDummyTravel(); - $travel->car = $car; - $travel->passenger = $passenger; - $travel->confirmed = true; - $this->manager->persist($travel); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedDummy with :nb friends - */ - public function thereIsARelatedDummyWithFriends(int $nb): void - { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy with friends'); - $this->manager->persist($relatedDummy); - $this->manager->flush(); - - for ($i = 1; $i <= $nb; ++$i) { - $friend = $this->buildDummyFriend(); - $friend->setName('Friend-'.$i); - - $this->manager->persist($friend); - // since doctrine 2.6 we need existing identifiers on relations - // See https://github.com/doctrine/doctrine2/pull/6701 - $this->manager->flush(); - - $relation = $this->buildRelatedToDummyFriend(); - $relation->setName('Relation-'.$i); - $relation->setDummyFriend($friend); - $relation->setRelatedDummy($relatedDummy); - - $relatedDummy->addRelatedToDummyFriend($relation); - - $this->manager->persist($relation); - } - - $relatedDummy2 = $this->buildRelatedDummy(); - $relatedDummy2->setName('RelatedDummy without friends'); - $this->manager->persist($relatedDummy2); - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is an answer :answer to the question :question - */ - public function thereIsAnAnswerToTheQuestion(string $a, string $q): void - { - $answer = $this->buildAnswer(); - $answer->setContent($a); - - $question = $this->buildQuestion(); - $question->setContent($q); - $question->setAnswer($answer); - $answer->addRelatedQuestion($question); - - $this->manager->persist($answer); - $this->manager->persist($question); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a UrlEncodedId resource - */ - public function thereIsAUrlEncodedIdResource(): void - { - $urlEncodedIdResource = ($this->isOrm() ? new UrlEncodedId() : new UrlEncodedIdDocument()); - $this->manager->persist($urlEncodedIdResource); - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a Program - */ - public function thereIsAProgram(): void - { - $this->thereArePrograms(1); - } - - /** - * @Given there are :nb Programs - */ - public function thereArePrograms(int $nb): void - { - $author = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find(1); - if (null === $author) { - $author = $this->isOrm() ? new User() : new UserDocument(); - $author->setEmail('john.doe@example.com'); - $author->setFullname('John DOE'); - $author->setPlainPassword('p4$$w0rd'); - - $this->manager->persist($author); - $this->manager->flush(); - } - - if ($this->isOrm()) { - $count = $this->doctrine->getRepository(Program::class)->count(['author' => $author]); - } else { - /** @var Builder */ - $qb = $this->doctrine->getRepository(ProgramDocument::class) - ->createQueryBuilder('f'); - $count = $qb->field('author')->equals($author) - ->count()->getQuery()->execute(); - } - - for ($i = (int) $count + 1; $i <= $nb; ++$i) { - $program = $this->isOrm() ? new Program() : new ProgramDocument(); - $program->name = "Lorem ipsum $i"; - $program->date = new \DateTimeImmutable(\sprintf('2015-03-0%dT10:00:00+00:00', $i)); - $program->author = $author; - - $this->manager->persist($program); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a Comment - */ - public function thereIsAComment(): void - { - $this->thereAreComments(1); - } - - /** - * @Given there are :nb Comments - */ - public function thereAreComments(int $nb): void - { - $author = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find(1); - if (null === $author) { - $author = $this->isOrm() ? new User() : new UserDocument(); - $author->setEmail('john.doe@example.com'); - $author->setFullname('John DOE'); - $author->setPlainPassword('p4$$w0rd'); - - $this->manager->persist($author); - $this->manager->flush(); - } - - if ($this->isOrm()) { - $count = $this->doctrine->getRepository(Comment::class)->count(['author' => $author]); - } else { - /** @var Builder $qb */ - $qb = $this->doctrine->getRepository(CommentDocument::class) - ->createQueryBuilder('f'); - - $count = $qb->field('author')->equals($author) - ->count()->getQuery()->execute(); - } - - for ($i = (int) $count + 1; $i <= $nb; ++$i) { - $comment = $this->isOrm() ? new Comment() : new CommentDocument(); - $comment->comment = "Lorem ipsum dolor sit amet $i"; - $comment->date = new \DateTimeImmutable(\sprintf('2015-03-0%dT10:00:00+00:00', $i)); - $comment->author = $author; - - $this->manager->persist($comment); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Then the password :password for user :user should be hashed - */ - public function thePasswordForUserShouldBeHashed(string $password, string $user): void - { - $user = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find($user); - if (!$this->passwordHasher->isPasswordValid($user, $password)) { - throw new \Exception('User password mismatch'); - } - } - - /** - * @Given I have a product with offers - */ - public function createProductWithOffers(): void - { - $offer = $this->buildDummyOffer(); - $offer->setId(1); - $offer->setValue(2); - - $aggregate = $this->buildDummyAggregateOffer(); - $aggregate->setValue(1); - $aggregate->addOffer($offer); - - $product = $this->buildDummyProduct(); - $product->setId(2); - $product->setName('Dummy product'); - $product->addOffer($aggregate); - - $relatedProduct = $this->buildDummyProduct(); - $relatedProduct->setName('Dummy related product'); - $relatedProduct->setId(1); - $relatedProduct->setParent($product); - - $product->addRelatedProduct($relatedProduct); - - $this->manager->persist($relatedProduct); - $this->manager->persist($product); - $this->manager->flush(); - } - - /** - * @Given there are people having pets - */ - public function createPeopleWithPets(): void - { - $personToPet = $this->buildPersonToPet(); - - $person = $this->buildPerson(); - $person->name = 'foo'; - - $pet = $this->buildPet(); - $pet->name = 'bar'; - - $personToPet->person = $person; - $personToPet->pet = $pet; - - $this->manager->persist($person); - $this->manager->persist($pet); - // since doctrine 2.6 we need existing identifiers on relations - $this->manager->flush(); - $this->manager->persist($personToPet); - - $person->pets->add($personToPet); - $this->manager->persist($person); - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with dummyDate - * @Given there is :nb dummydate object with dummyDate - */ - public function thereAreDummyDateObjectsWithDummyDate(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullAfter - * @Given there is :nb dummydate object with nullable dateIncludeNullAfter - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullAfter(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullAfter = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullBefore - * @Given there is :nb dummydate object with nullable dateIncludeNullBefore - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullBefore(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullBefore = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullBeforeAndAfter - * @Given there is :nb dummydate object with nullable dateIncludeNullBeforeAndAfter - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullBeforeAndAfter(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullBeforeAndAfter = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyimmutabledate objects with dummyDate - */ - public function thereAreDummyImmutableDateObjectsWithDummyDate(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTimeImmutable(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - $dummy = $this->buildDummyImmutableDate(); - $dummy->dummyDate = $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy with different GraphQL serialization groups objects - */ - public function thereAreDummyWithDifferentGraphQlSerializationGroupsObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDifferentGraphQlSerializationGroup = $this->buildDummyDifferentGraphQlSerializationGroup(); - $dummyDifferentGraphQlSerializationGroup->setName('Name #'.$i); - $dummyDifferentGraphQlSerializationGroup->setTitle('Title #'.$i); - $this->manager->persist($dummyDifferentGraphQlSerializationGroup); - } - - $this->manager->flush(); - } - - /** - * @Given there is a ramsey identified resource with uuid :uuid - * - * @param non-empty-string $uuid - */ - public function thereIsARamseyIdentifiedResource(string $uuid): void - { - $dummy = new RamseyUuidDummy(Uuid::fromString($uuid)); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a Symfony dummy identified resource with uuid :uuid - */ - public function thereIsASymfonyDummyIdentifiedResource(string $uuid): void - { - $dummy = new SymfonyUuidDummy(SymfonyUuid::fromString($uuid)); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with a fourth level relation - */ - public function thereIsADummyObjectWithAFourthLevelRelation(): void - { - $fourthLevel = $this->buildFourthLevel(); - $fourthLevel->setLevel(4); - $this->manager->persist($fourthLevel); - - $thirdLevel = $this->buildThirdLevel(); - $thirdLevel->setLevel(3); - $thirdLevel->setFourthLevel($fourthLevel); - $this->manager->persist($thirdLevel); - - $namedRelatedDummy = $this->buildRelatedDummy(); - $namedRelatedDummy->setName('Hello'); - $namedRelatedDummy->setThirdLevel($thirdLevel); - $this->manager->persist($namedRelatedDummy); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setThirdLevel($thirdLevel); - $this->manager->persist($relatedDummy); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - $dummy->setRelatedDummy($namedRelatedDummy); - $dummy->addRelatedDummy($namedRelatedDummy); - $dummy->addRelatedDummy($relatedDummy); - $this->manager->persist($dummy); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedOwnedDummy object with OneToOne relation - */ - public function thereIsARelatedOwnedDummy(): void - { - $relatedOwnedDummy = $this->buildRelatedOwnedDummy(); - $this->manager->persist($relatedOwnedDummy); - - $dummy = $this->buildDummy(); - $dummy->setName('plop'); - $dummy->setRelatedOwnedDummy($relatedOwnedDummy); - $this->manager->persist($dummy); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedOwningDummy object with OneToOne relation - */ - public function thereIsARelatedOwningDummy(): void - { - $dummy = $this->buildDummy(); - $dummy->setName('plop'); - $this->manager->persist($dummy); - - $relatedOwningDummy = $this->buildRelatedOwningDummy(); - $relatedOwningDummy->setOwnedDummy($dummy); - $this->manager->persist($relatedOwningDummy); - - $this->manager->flush(); - } - - /** - * @Given there is a person named :name greeting with a :message message - */ - public function thereIsAPersonWithAGreeting(string $name, string $message): void - { - $person = $this->buildPerson(); - $person->name = $name; - - $greeting = $this->buildGreeting(); - $greeting->message = $message; - $greeting->sender = $person; - - $this->manager->persist($person); - $this->manager->persist($greeting); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a max depth dummy with :level level of descendants - */ - public function thereIsAMaxDepthDummyWithLevelOfDescendants(int $level): void - { - $maxDepthDummy = $this->buildMaxDepthDummy(); - $maxDepthDummy->name = "level $level"; - $this->manager->persist($maxDepthDummy); - - for ($i = 1; $i <= $level; ++$i) { - $maxDepthDummy = $maxDepthDummy->child = $this->buildMaxDepthDummy(); - $maxDepthDummy->name = 'level '.($i + 1); - $this->manager->persist($maxDepthDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a DummyDtoCustom - */ - public function thereIsADummyDtoCustom(): void - { - $this->thereAreNbDummyDtoCustom(1); - } - - /** - * @Given there are :nb DummyDtoCustom - */ - public function thereAreNbDummyDtoCustom($nb): void - { - for ($i = 0; $i < $nb; ++$i) { - $dto = $this->isOrm() ? new DummyDtoCustom() : new DummyDtoCustomDocument(); - $dto->lorem = 'test'; - $dto->ipsum = (string) ($i + 1); - $this->manager->persist($dto); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is an order with same customer and recipient - */ - public function thereIsAnOrderWithSameCustomerAndRecipient(): void - { - $customer = $this->isOrm() ? new Customer() : new CustomerDocument(); - $customer->name = 'customer_name'; - - $address1 = $this->isOrm() ? new Address() : new AddressDocument(); - $address1->name = 'foo'; - $address2 = $this->isOrm() ? new Address() : new AddressDocument(); - $address2->name = 'bar'; - - $order = $this->isOrm() ? new Order() : new OrderDocument(); - $order->recipient = $customer; - $order->customer = $customer; - - $customer->addresses->add($address1); - $customer->addresses->add($address2); - - $this->manager->persist($address1); - $this->manager->persist($address2); - $this->manager->persist($customer); - $this->manager->persist($order); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there are :nb sites with internal owner - */ - public function thereAreSitesWithInternalOwner(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $internalUser = new InternalUser(); - $internalUser->setFirstname('Internal'); - $internalUser->setLastname('User'); - $internalUser->setEmail('john.doe@example.com'); - $internalUser->setInternalId('INT'); - $site = new Site(); - $site->setTitle('title'); - $site->setDescription('description'); - $site->setOwner($internalUser); - $this->manager->persist($site); - } - $this->manager->flush(); - } - - /** - * @Given there are :nb sites with external owner - */ - public function thereAreSitesWithExternalOwner(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $externalUser = new ExternalUser(); - $externalUser->setFirstname('External'); - $externalUser->setLastname('User'); - $externalUser->setEmail('john.doe@example.com'); - $externalUser->setExternalId('EXT'); - $site = new Site(); - $site->setTitle('title'); - $site->setDescription('description'); - $site->setOwner($externalUser); - $this->manager->persist($site); - } - $this->manager->flush(); - } - - /** - * @Given there is the following taxon: - */ - public function thereIsTheFollowingTaxon(PyStringNode $dataNode): void - { - $data = json_decode((string) $dataNode, true, 512, \JSON_THROW_ON_ERROR); - - $taxon = $this->isOrm() ? new Taxon() : new TaxonDocument(); - $taxon->setCode($data['code']); - $this->manager->persist($taxon); - - $this->manager->flush(); - } - - /** - * @Given there is the following product: - */ - public function thereIsTheFollowingProduct(PyStringNode $dataNode): void - { - $data = json_decode((string) $dataNode, true, 512, \JSON_THROW_ON_ERROR); - - $product = $this->isOrm() ? new Product() : new ProductDocument(); - $product->setCode($data['code']); - if (isset($data['mainTaxon'])) { - $mainTaxonCode = str_replace('/taxa/', '', $data['mainTaxon']); - $mainTaxon = $this->manager->getRepository($this->isOrm() ? Taxon::class : TaxonDocument::class)->findOneBy([ - 'code' => $mainTaxonCode, - ]); - $product->setMainTaxon($mainTaxon); - } - $this->manager->persist($product); - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedOwner objects with convertedRelated - */ - public function thereAreConvertedOwnerObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $related = $this->buildConvertedRelated(); - $related->nameConverted = 'Converted '.$i; - - $owner = $this->buildConvertedOwner(); - $owner->nameConverted = $related; - - $this->manager->persist($related); - $this->manager->persist($owner); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy mercure objects - */ - public function thereAreDummyMercureObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - - $dummyMercure = $this->buildDummyMercure(); - $dummyMercure->name = "Dummy Mercure #$i"; - $dummyMercure->description = 'Description'; - $dummyMercure->relatedDummy = $relatedDummy; - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummyMercure); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb iriOnlyDummies - */ - public function thereAreIriOnlyDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $iriOnlyDummy = $this->buildIriOnlyDummy(); - $iriOnlyDummy->setFoo('bar'.$nb); - $this->manager->persist($iriOnlyDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are propertyCollectionIriOnly with relations - */ - public function thereAreResourcesWithPropertyUriTemplates(): void - { - $propertyCollectionIriOnlyRelation1 = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument(); - $propertyCollectionIriOnlyRelation1->name = 'asb1'; - - $propertyCollectionIriOnlyRelation2 = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument(); - $propertyCollectionIriOnlyRelation2->name = 'asb2'; - - $propertyToOneRelation = $this->isOrm() ? new PropertyUriTemplateOneToOneRelation() : new PropertyUriTemplateOneToOneRelationDocument(); - $propertyToOneRelation->name = 'xarguš'; - - $propertyCollectionIriOnly = $this->isOrm() ? new PropertyCollectionIriOnly() : new PropertyCollectionIriOnlyDocument(); - $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation1); - $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation2); - $propertyCollectionIriOnly->setToOneRelation($propertyToOneRelation); - - $this->manager->persist($propertyCollectionIriOnly); - $this->manager->persist($propertyCollectionIriOnlyRelation1); - $this->manager->persist($propertyCollectionIriOnlyRelation2); - $this->manager->persist($propertyToOneRelation); - $this->manager->flush(); - } - - /** - * @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy - */ - public function thereAreAbsoluteUrlDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $absoluteUrlRelationDummy = $this->buildAbsoluteUrlRelationDummy(); - $absoluteUrlDummy = $this->buildAbsoluteUrlDummy(); - $absoluteUrlDummy->absoluteUrlRelationDummy = $absoluteUrlRelationDummy; - - $this->manager->persist($absoluteUrlRelationDummy); - $this->manager->persist($absoluteUrlDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb networkPathDummy objects with a related networkPathRelationDummy - */ - public function thereAreNetworkPathDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $networkPathRelationDummy = $this->buildNetworkPathRelationDummy(); - $networkPathDummy = $this->buildNetworkPathDummy(); - $networkPathDummy->networkPathRelationDummy = $networkPathRelationDummy; - - $this->manager->persist($networkPathRelationDummy); - $this->manager->persist($networkPathDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is an InitializeInput object with id :id - */ - public function thereIsAnInitializeInput(int $id): void - { - $initializeInput = $this->buildInitializeInput(); - $initializeInput->id = $id; - $initializeInput->manager = 'Orwell'; - $initializeInput->name = '1984'; - - $this->manager->persist($initializeInput); - $this->manager->flush(); - } - - /** - * @Given there is a PatchDummyRelation - */ - public function thereIsAPatchDummyRelation(): void - { - $dummy = $this->buildPatchDummyRelation(); - $related = $this->buildRelatedDummy(); - $this->manager->persist($related); - $this->manager->flush(); - $dummy->setRelated($related); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a book - */ - public function thereIsABook(): void - { - $book = $this->buildBook(); - $book->name = '1984'; - $book->isbn = '9780451524935'; - $this->manager->persist($book); - $this->manager->flush(); - } - - /** - * @Given there is a custom multiple identifier dummy - */ - public function thereIsACustomMultipleIdentifierDummy(): void - { - $dummy = $this->buildCustomMultipleIdentifierDummy(); - $dummy->setName('Orwell'); - $dummy->setFirstId(1); - $dummy->setSecondId(2); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a payment - */ - public function thereIsAPayment(): void - { - $this->manager->persist($this->buildPayment('123.45')); - $this->manager->flush(); - } - - /** - * @Given there are :nb separated entities - */ - public function thereAreSeparatedEntities(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $entity = $this->buildSeparatedEntity(); - $entity->value = (string) $i; - $this->manager->persist($entity); - } - $this->manager->flush(); - } - - /** - * @Given there is a video game with music groups - */ - public function thereAreVideoGamesWithMusicGroups(): void - { - $sum41 = $this->buildMusicGroup(); - $sum41->name = 'Sum 41'; - $this->manager->persist($sum41); - $franz = $this->buildMusicGroup(); - $franz->name = 'Franz Ferdinand'; - $this->manager->persist($franz); - - $videoGame = $this->buildVideoGame(); - $videoGame->name = 'Guitar Hero'; - $videoGame->addMusicGroup($sum41); - $videoGame->addMusicGroup($franz); - $this->manager->persist($videoGame); - $this->manager->flush(); - } - - /** - * @Given there is a relationMultiple object - */ - public function thereIsARelationMultipleObject(): void - { - $first = $this->buildDummy(); - $first->setId(1); - $first->setName('foo'); - $second = $this->buildDummy(); - $second->setId(2); - $second->setName('bar'); - - $relationMultiple = (new RelationMultiple()); - $relationMultiple->first = $first; - $relationMultiple->second = $second; - - $this->manager->persist($first); - $this->manager->persist($second); - $this->manager->persist($relationMultiple); - - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with many multiple relation - */ - public function thereIsADummyObjectWithManyMultipleRelation(): void - { - $first = $this->buildDummy(); - $first->setId(1); - $first->setName('foo'); - $second = $this->buildDummy(); - $second->setId(2); - $second->setName('bar'); - $third = $this->buildDummy(); - $third->setId(3); - $third->setName('foobar'); - - $relationMultiple1 = (new RelationMultiple()); - $relationMultiple1->first = $first; - $relationMultiple1->second = $second; - - $relationMultiple2 = (new RelationMultiple()); - $relationMultiple2->first = $first; - $relationMultiple2->second = $third; - - $this->manager->persist($first); - $this->manager->persist($second); - $this->manager->persist($third); - $this->manager->persist($relationMultiple1); - $this->manager->persist($relationMultiple2); - - $this->manager->flush(); - } - - /** - * @Given there is a resource using entityClass with a DateTime attribute - */ - public function thereIsAResourceUsingEntityClassAndDateTime(): void - { - $entity = new EntityClassWithDateTime(); - $entity->setStart(new \DateTime()); - $this->manager->persist($entity); - $this->manager->flush(); - } - - /** - * @Given there is a dummy entity with a sub entity with id :strId and name :name - */ - public function thereIsADummyWithSubEntity(string $strId, string $name): void - { - $subEntity = new DummySubEntity($strId, $name); - $mainEntity = new DummyWithSubEntity(); - $mainEntity->setSubEntity($subEntity); - $mainEntity->setName('main'); - $this->manager->persist($subEntity); - $this->manager->persist($mainEntity); - $this->manager->flush(); - } - - /** - * @Given there is a group object with uuid :uuid and :nbUsers users - */ - public function thereIsAGroupWithUuidAndNUsers(string $uuid, int $nbUsers): void - { - $group = new Group(); - $group->setUuid(SymfonyUuid::fromString($uuid)); - - $this->manager->persist($group); - - for ($i = 0; $i < $nbUsers; ++$i) { - $user = new \ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User(); - $user->addGroup($group); - $this->manager->persist($user); - } - - // add another user not in this group - $user = new \ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User(); - $this->manager->persist($user); - - $this->manager->flush(); - } - - /** - * @Given there are logs on an event - */ - public function thereAreLogsOnAnEvent(): void - { - $entity = new Event(); - $entity->logs = new ArrayCollection([new ItemLog(), new ItemLog()]); - $entity->uuid = Uuid::fromString('03af3507-271e-4cca-8eee-6244fb06e95b'); - $this->manager->persist($entity); - foreach ($entity->logs as $log) { - $log->item = $entity; - $this->manager->persist($log); - } - - $this->manager->flush(); - } - - /** - * @Given there are a few link handled dummies - */ - public function thereAreAFewLinkHandledDummies(): void - { - $this->manager->persist($this->buildLinkHandledDummy('foo')); - $this->manager->persist($this->buildLinkHandledDummy('bar')); - $this->manager->persist($this->buildLinkHandledDummy('baz')); - $this->manager->persist($this->buildLinkHandledDummy('foz')); - $this->manager->flush(); - } - - /** - * @Given there is a dummy entity with a mapped superclass - */ - public function thereIsADummyEntityWithAMappedSuperclass(): void - { - $entity = new DummyMappedSubclass(); - $this->manager->persist($entity); - $this->manager->flush(); - } - - /** - * @Given there are issue6039 users - */ - public function thereAreIssue6039Users(): void - { - $entity = new Issue6039EntityUser(); - $entity->name = 'test'; - $entity->bar = 'test'; - $this->manager->persist($entity); - $entity = new Issue6039EntityUser(); - $entity->name = 'test2'; - $entity->bar = 'test'; - $this->manager->persist($entity); - $this->manager->flush(); - } - - private function isOrm(): bool - { - return null !== $this->schemaTool; - } - - private function isOdm(): bool - { - return null !== $this->schemaManager; - } - - private function buildAnswer(): Answer|AnswerDocument - { - return $this->isOrm() ? new Answer() : new AnswerDocument(); - } - - private function buildCompositeItem(): CompositeItem|CompositeItemDocument - { - return $this->isOrm() ? new CompositeItem() : new CompositeItemDocument(); - } - - private function buildCompositeLabel(): CompositeLabel|CompositeLabelDocument - { - return $this->isOrm() ? new CompositeLabel() : new CompositeLabelDocument(); - } - - private function buildCompositePrimitiveItem(string $name, int $year): CompositePrimitiveItem|CompositePrimitiveItemDocument - { - return $this->isOrm() ? new CompositePrimitiveItem($name, $year) : new CompositePrimitiveItemDocument($name, $year); - } - - private function buildCompositeRelation(): CompositeRelation|CompositeRelationDocument - { - return $this->isOrm() ? new CompositeRelation() : new CompositeRelationDocument(); - } - - private function buildDummy(): Dummy|DummyDocument - { - return $this->isOrm() ? new Dummy() : new DummyDocument(); - } - - private function buildDummyTableInheritanceNotApiResourceChild(): DummyTableInheritanceNotApiResourceChild|DummyTableInheritanceNotApiResourceChildDocument - { - return $this->isOrm() ? new DummyTableInheritanceNotApiResourceChild() : new DummyTableInheritanceNotApiResourceChildDocument(); - } - - private function buildDummyAggregateOffer(): DummyAggregateOffer|DummyAggregateOfferDocument - { - return $this->isOrm() ? new DummyAggregateOffer() : new DummyAggregateOfferDocument(); - } - - private function buildDummyCar(): DummyCar|DummyCarDocument - { - return $this->isOrm() ? new DummyCar() : new DummyCarDocument(); - } - - private function buildDummyCarColor(): DummyCarColor|DummyCarColorDocument - { - return $this->isOrm() ? new DummyCarColor() : new DummyCarColorDocument(); - } - - private function buildDummyPassenger(): DummyPassenger|DummyPassengerDocument - { - return $this->isOrm() ? new DummyPassenger() : new DummyPassengerDocument(); - } - - private function buildDummyTravel(): DummyTravel|DummyTravelDocument - { - return $this->isOrm() ? new DummyTravel() : new DummyTravelDocument(); - } - - private function buildDummyDate(): DummyDate|DummyDateDocument - { - return $this->isOrm() ? new DummyDate() : new DummyDateDocument(); - } - - private function buildDummyImmutableDate(): DummyImmutableDate|DummyImmutableDateDocument - { - return $this->isOrm() ? new DummyImmutableDate() : new DummyImmutableDateDocument(); - } - - private function buildDummyDifferentGraphQlSerializationGroup(): DummyDifferentGraphQlSerializationGroup|DummyDifferentGraphQlSerializationGroupDocument - { - return $this->isOrm() ? new DummyDifferentGraphQlSerializationGroup() : new DummyDifferentGraphQlSerializationGroupDocument(); - } - - private function buildDummyDtoNoInput(): DummyDtoNoInput|DummyDtoNoInputDocument - { - return $this->isOrm() ? new DummyDtoNoInput() : new DummyDtoNoInputDocument(); - } - - private function buildDummyDtoNoOutput(): DummyDtoNoOutput|DummyDtoNoOutputDocument - { - return $this->isOrm() ? new DummyDtoNoOutput() : new DummyDtoNoOutputDocument(); - } - - private function buildDummyCustomQuery(): DummyCustomQuery|DummyCustomQueryDocument - { - return $this->isOrm() ? new DummyCustomQuery() : new DummyCustomQueryDocument(); - } - - private function buildDummyCustomMutation(): DummyCustomMutation|DummyCustomMutationDocument - { - return $this->isOrm() ? new DummyCustomMutation() : new DummyCustomMutationDocument(); - } - - private function buildDummyFriend(): DummyFriend|DummyFriendDocument - { - return $this->isOrm() ? new DummyFriend() : new DummyFriendDocument(); - } - - private function buildDummyGroup(): DummyGroup|DummyGroupDocument - { - return $this->isOrm() ? new DummyGroup() : new DummyGroupDocument(); - } - - private function buildDummyOffer(): DummyOffer|DummyOfferDocument - { - return $this->isOrm() ? new DummyOffer() : new DummyOfferDocument(); - } - - private function buildDummyProduct(): DummyProduct|DummyProductDocument - { - return $this->isOrm() ? new DummyProduct() : new DummyProductDocument(); - } - - private function buildDummyProperty(): DummyProperty|DummyPropertyDocument - { - return $this->isOrm() ? new DummyProperty() : new DummyPropertyDocument(); - } - - private function buildEmbeddableDummy(): EmbeddableDummy|EmbeddableDummyDocument - { - return $this->isOrm() ? new EmbeddableDummy() : new EmbeddableDummyDocument(); - } - - private function buildEmbeddedDummy(): EmbeddedDummy|EmbeddedDummyDocument - { - return $this->isOrm() ? new EmbeddedDummy() : new EmbeddedDummyDocument(); - } - - private function buildFileConfigDummy(): FileConfigDummy|FileConfigDummyDocument - { - return $this->isOrm() ? new FileConfigDummy() : new FileConfigDummyDocument(); - } - - private function buildFoo(): Foo|FooDocument - { - return $this->isOrm() ? new Foo() : new FooDocument(); - } - - private function buildFooDummy(): FooDummy|FooDummyDocument - { - return $this->isOrm() ? new FooDummy() : new FooDummyDocument(); - } - - private function buildFooEmbeddable(): FooEmbeddable|FooEmbeddableDocument - { - return $this->isOrm() ? new FooEmbeddable() : new FooEmbeddableDocument(); - } - - private function buildFourthLevel(): FourthLevel|FourthLevelDocument - { - return $this->isOrm() ? new FourthLevel() : new FourthLevelDocument(); - } - - private function buildGreeting(): Greeting|GreetingDocument - { - return $this->isOrm() ? new Greeting() : new GreetingDocument(); - } - - private function buildIriOnlyDummy(): IriOnlyDummy|IriOnlyDummyDocument - { - return $this->isOrm() ? new IriOnlyDummy() : new IriOnlyDummyDocument(); - } - - private function buildMaxDepthDummy(): MaxDepthDummy|MaxDepthDummyDocument - { - return $this->isOrm() ? new MaxDepthDummy() : new MaxDepthDummyDocument(); - } - - private function buildPerson(): Person|PersonDocument - { - return $this->isOrm() ? new Person() : new PersonDocument(); - } - - private function buildPersonToPet(): PersonToPet|PersonToPetDocument - { - return $this->isOrm() ? new PersonToPet() : new PersonToPetDocument(); - } - - private function buildPet(): Pet|PetDocument - { - return $this->isOrm() ? new Pet() : new PetDocument(); - } - - private function buildQuestion(): Question|QuestionDocument - { - return $this->isOrm() ? new Question() : new QuestionDocument(); - } - - private function buildRelatedDummy(): RelatedDummy|RelatedDummyDocument - { - return $this->isOrm() ? new RelatedDummy() : new RelatedDummyDocument(); - } - - private function buildRelatedOwnedDummy(): RelatedOwnedDummy|RelatedOwnedDummyDocument - { - return $this->isOrm() ? new RelatedOwnedDummy() : new RelatedOwnedDummyDocument(); - } - - private function buildRelatedOwningDummy(): RelatedOwningDummy|RelatedOwningDummyDocument - { - return $this->isOrm() ? new RelatedOwningDummy() : new RelatedOwningDummyDocument(); - } - - private function buildRelatedToDummyFriend(): RelatedToDummyFriend|RelatedToDummyFriendDocument - { - return $this->isOrm() ? new RelatedToDummyFriend() : new RelatedToDummyFriendDocument(); - } - - private function buildRelatedLinkedDummy(): RelatedLinkedDummy|RelatedLinkedDummyDocument - { - return $this->isOrm() ? new RelatedLinkedDummy() : new RelatedLinkedDummyDocument(); - } - - private function buildRelationEmbedder(): RelationEmbedder|RelationEmbedderDocument - { - return $this->isOrm() ? new RelationEmbedder() : new RelationEmbedderDocument(); - } - - private function buildSecuredDummy(): SecuredDummy|SecuredDummyDocument - { - return $this->isOrm() ? new SecuredDummy() : new SecuredDummyDocument(); - } - - private function buildRelatedSecureDummy(): RelatedSecuredDummy|RelatedSecuredDummyDocument - { - return $this->isOrm() ? new RelatedSecuredDummy() : new RelatedSecuredDummyDocument(); - } - - private function buildSoMany(): SoMany|SoManyDocument - { - return $this->isOrm() ? new SoMany() : new SoManyDocument(); - } - - private function buildThirdLevel(): ThirdLevel|ThirdLevelDocument - { - return $this->isOrm() ? new ThirdLevel() : new ThirdLevelDocument(); - } - - private function buildConvertedDate(): ConvertedDate|ConvertedDateDocument - { - return $this->isOrm() ? new ConvertedDate() : new ConvertedDateDocument(); - } - - private function buildConvertedBoolean(): ConvertedBoolean|ConvertedBoolDocument - { - return $this->isOrm() ? new ConvertedBoolean() : new ConvertedBoolDocument(); - } - - private function buildConvertedInteger(): ConvertedInteger|ConvertedIntegerDocument - { - return $this->isOrm() ? new ConvertedInteger() : new ConvertedIntegerDocument(); - } - - private function buildConvertedString(): ConvertedString|ConvertedStringDocument - { - return $this->isOrm() ? new ConvertedString() : new ConvertedStringDocument(); - } - - private function buildConvertedOwner(): ConvertedOwner|ConvertedOwnerDocument - { - return $this->isOrm() ? new ConvertedOwner() : new ConvertedOwnerDocument(); - } - - private function buildConvertedRelated(): ConvertedRelated|ConvertedRelatedDocument - { - return $this->isOrm() ? new ConvertedRelated() : new ConvertedRelatedDocument(); - } - - private function buildDummyMercure(): DummyMercure|DummyMercureDocument - { - return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); - } - - private function buildAbsoluteUrlDummy(): AbsoluteUrlDummyDocument|AbsoluteUrlDummy - { - return $this->isOrm() ? new AbsoluteUrlDummy() : new AbsoluteUrlDummyDocument(); - } - - private function buildAbsoluteUrlRelationDummy(): AbsoluteUrlRelationDummyDocument|AbsoluteUrlRelationDummy - { - return $this->isOrm() ? new AbsoluteUrlRelationDummy() : new AbsoluteUrlRelationDummyDocument(); - } - - private function buildNetworkPathDummy(): NetworkPathDummyDocument|NetworkPathDummy - { - return $this->isOrm() ? new NetworkPathDummy() : new NetworkPathDummyDocument(); - } - - private function buildNetworkPathRelationDummy(): NetworkPathRelationDummyDocument|NetworkPathRelationDummy - { - return $this->isOrm() ? new NetworkPathRelationDummy() : new NetworkPathRelationDummyDocument(); - } - - private function buildInitializeInput(): InitializeInput|InitializeInputDocument - { - return $this->isOrm() ? new InitializeInput() : new InitializeInputDocument(); - } - - private function buildPatchDummyRelation(): PatchDummyRelation|PatchDummyRelationDocument - { - return $this->isOrm() ? new PatchDummyRelation() : new PatchDummyRelationDocument(); - } - - private function buildBook(): BookDocument|Book - { - return $this->isOrm() ? new Book() : new BookDocument(); - } - - private function buildCustomMultipleIdentifierDummy(): CustomMultipleIdentifierDummy|CustomMultipleIdentifierDummyDocument - { - return $this->isOrm() ? new CustomMultipleIdentifierDummy() : new CustomMultipleIdentifierDummyDocument(); - } - - private function buildWithJsonDummy(): WithJsonDummy|WithJsonDummyDocument - { - return $this->isOrm() ? new WithJsonDummy() : new WithJsonDummyDocument(); - } - - private function buildPayment(string $amount): Payment|PaymentDocument - { - return $this->isOrm() ? new Payment($amount) : new PaymentDocument($amount); - } - - private function buildMultiRelationsDummy(): MultiRelationsDummy|MultiRelationsDummyDocument - { - return $this->isOrm() ? new MultiRelationsDummy() : new MultiRelationsDummyDocument(); - } - - private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|MultiRelationsRelatedDummyDocument - { - return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument(); - } - - private function buildMultiRelationsNested(): MultiRelationsNested|MultiRelationsNestedDocument - { - return $this->isOrm() ? new MultiRelationsNested() : new MultiRelationsNestedDocument(); - } - - private function buildMultiRelationsNestedPaginated(): MultiRelationsNestedPaginated|MultiRelationsNestedPaginatedDocument - { - return $this->isOrm() ? new MultiRelationsNestedPaginated() : new MultiRelationsNestedPaginatedDocument(); - } - - private function buildMultiRelationsResolveDummy(): MultiRelationsResolveDummy|MultiRelationsResolveDummyDocument - { - return $this->isOrm() ? new MultiRelationsResolveDummy() : new MultiRelationsResolveDummyDocument(); - } - - private function buildMusicGroup(): MusicGroup|MusicGroupDocument - { - return $this->isOrm() ? new MusicGroup() : new MusicGroupDocument(); - } - - private function buildVideoGame(): VideoGame|VideoGameDocument - { - return $this->isOrm() ? new VideoGame() : new VideoGameDocument(); - } - - private function buildSeparatedEntity(): SeparatedEntity|SeparatedEntityDocument - { - return $this->isOrm() ? new SeparatedEntity() : new SeparatedEntityDocument(); - } - - private function buildLinkHandledDummy(string $slug): LinkHandledDummy|LinkHandledDummyDocument - { - return $this->isOrm() ? new LinkHandledDummy($slug) : new LinkHandledDummyDocument($slug); - } -} diff --git a/tests/Behat/GraphqlContext.php b/tests/Behat/GraphqlContext.php deleted file mode 100644 index ca644baaff9..00000000000 --- a/tests/Behat/GraphqlContext.php +++ /dev/null @@ -1,178 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use Behatch\Context\RestContext; -use Behatch\HttpCall\Request; -use GraphQL\Error\Error; -use GraphQL\Type\Introspection; -use PHPUnit\Framework\ExpectationFailedException; - -/** - * Context for GraphQL. - * - * @author Alan Poulain - */ -final class GraphqlContext implements Context -{ - private ?RestContext $restContext = null; - private ?JsonContext $jsonContext = null; - - private array $graphqlRequest; - - private ?int $graphqlLine = null; // @phpstan-ignore-line - - public function __construct(private readonly Request $request) - { - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** @var InitializedContextEnvironment $environment */ - $environment = $scope->getEnvironment(); - /** @var RestContext $restContext */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - /** @var JsonContext $jsonContext */ - $jsonContext = $environment->getContext(JsonContext::class); - $this->jsonContext = $jsonContext; - } - - /** - * @When I have the following GraphQL request: - */ - public function IHaveTheFollowingGraphqlRequest(PyStringNode $request): void - { - $this->graphqlRequest = ['query' => $request->getRaw()]; - $this->graphqlLine = $request->getLine(); - } - - /** - * @When I send the following GraphQL request: - */ - public function ISendTheFollowingGraphqlRequest(PyStringNode $request): void - { - $this->IHaveTheFollowingGraphqlRequest($request); - $this->sendGraphqlRequest(); - } - - /** - * @When I send the GraphQL request with variables: - */ - public function ISendTheGraphqlRequestWithVariables(PyStringNode $variables): void - { - $this->graphqlRequest['variables'] = $variables->getRaw(); - $this->sendGraphqlRequest(); - } - - /** - * @When I send the GraphQL request with operationName :operationName - */ - public function ISendTheGraphqlRequestWithOperation(string $operationName): void - { - $this->graphqlRequest['operationName'] = $operationName; - $this->sendGraphqlRequest(); - } - - /** - * @Given I have the following file(s) for a GraphQL request: - */ - public function iHaveTheFollowingFilesForAGraphqlRequest(TableNode $table): void - { - $files = []; - - foreach ($table->getHash() as $row) { - if (!isset($row['name'], $row['file'])) { - throw new \InvalidArgumentException('You must provide a "name" and "file" column in your table node.'); - } - - $files[$row['name']] = $this->restContext->getMinkParameter('files_path').\DIRECTORY_SEPARATOR.$row['file']; - } - - $this->graphqlRequest['files'] = $files; - } - - /** - * @Given I have the following GraphQL multipart request map: - */ - public function iHaveTheFollowingGraphqlMultipartRequestMap(PyStringNode $string): void - { - $this->graphqlRequest['map'] = $string->getRaw(); - } - - /** - * @When I send the following GraphQL multipart request operations: - */ - public function iSendTheFollowingGraphqlMultipartRequestOperations(PyStringNode $string): void - { - $params = []; - $params['operations'] = $string->getRaw(); - $params['map'] = $this->graphqlRequest['map']; - - $this->request->setHttpHeader('Content-type', 'multipart/form-data'); - $this->request->send('POST', '/graphql', $params, $this->graphqlRequest['files']); - } - - /** - * @When I send the query to introspect the schema - */ - public function ISendTheQueryToIntrospectTheSchema(): void - { - $this->graphqlRequest = ['query' => Introspection::getIntrospectionQuery()]; - $this->sendGraphqlRequest(); - } - - /** - * @Then the GraphQL field :fieldName is deprecated for the reason :reason - */ - public function theGraphQLFieldIsDeprecatedForTheReason(string $fieldName, string $reason): void - { - foreach (json_decode($this->request->getContent(), true, 512, \JSON_THROW_ON_ERROR)['data']['__type']['fields'] as $field) { - if ($fieldName === $field['name'] && $field['isDeprecated'] && $reason === $field['deprecationReason']) { - return; - } - } - - throw new ExpectationFailedException(\sprintf('The field "%s" is not deprecated.', $fieldName)); - } - - /** - * @Then the GraphQL debug message should be equal to :expectedDebugMessage - */ - public function theGraphQLDebugMessageShouldBeEqualTo(string $expectedDebugMessage): void - { - $jsonNode = 'errors[0].extensions.debugMessage'; - // graphql-php < 15 - if (\defined(Error::class.'::CATEGORY_INTERNAL')) { - $jsonNode = 'errors[0].debugMessage'; - } - - $this->jsonContext->theJsonNodeShouldBeEqualTo($jsonNode, $expectedDebugMessage); - } - - private function sendGraphqlRequest(): void - { - $this->restContext->iSendARequestTo('GET', '/graphql?'.http_build_query($this->graphqlRequest)); - } -} diff --git a/tests/Behat/HttpCacheContext.php b/tests/Behat/HttpCacheContext.php deleted file mode 100644 index d06ba3414eb..00000000000 --- a/tests/Behat/HttpCacheContext.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorCustom; -use Behat\Behat\Context\Context; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behat\Mink\Driver\BrowserKitDriver; -use Behat\MinkExtension\Context\MinkContext; -use FriendsOfBehat\SymfonyExtension\Context\Environment\InitializedSymfonyExtensionEnvironment; -use PHPUnit\Framework\ExpectationFailedException; -use Symfony\Bundle\FrameworkBundle\KernelBrowser; -use Symfony\Component\DependencyInjection\ContainerInterface; - -/** - * @author Kévin Dunglas - */ -final class HttpCacheContext implements Context -{ - public function __construct(private ContainerInterface $driverContainer) - { - } - - /** - * @BeforeScenario @customTagCollector - */ - public function registerCustomTagCollector(BeforeScenarioScope $scope): void - { - $this->disableReboot($scope); - /** @phpstan-ignore-next-line */ - $iriConverter = $this->driverContainer->get('api_platform.iri_converter'); - $this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom($iriConverter)); - } - - /** - * @Then :iris IRIs should be purged - */ - public function irisShouldBePurged(string $iris): void - { - $purger = $this->driverContainer->get('test.api_platform.http_cache.purger'); - - $iris = explode(',', $iris); - sort($iris); - $iris = implode(',', $iris); - - $purgedIris = $purger->getIris(); - sort($purgedIris); - $purgedIris = implode(',', $purgedIris); - - $purger->clear(); - - if ($iris !== $purgedIris) { - throw new ExpectationFailedException(\sprintf('IRIs "%s" does not match expected "%s".', $purgedIris, $iris)); - } - } - - /** - * this is necessary to allow overriding services - * see https://github.com/FriendsOfBehat/SymfonyExtension/issues/149 for details. - */ - private function disableReboot(BeforeScenarioScope $scope): void - { - $env = $scope->getEnvironment(); - if (!$env instanceof InitializedSymfonyExtensionEnvironment) { - return; - } - - $driver = $env->getContext(MinkContext::class)->getSession()->getDriver(); - if (!$driver instanceof BrowserKitDriver) { - return; - } - - $client = $driver->getClient(); - if (!$client instanceof KernelBrowser) { - return; - } - - $client->disableReboot(); - } -} diff --git a/tests/Behat/HydraContext.php b/tests/Behat/HydraContext.php deleted file mode 100644 index a0425ac2b13..00000000000 --- a/tests/Behat/HydraContext.php +++ /dev/null @@ -1,326 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use PHPUnit\Framework\Assert; -use PHPUnit\Framework\ExpectationFailedException; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; - -final class HydraContext implements Context -{ - private ?RestContext $restContext = null; - - public function __construct(private readonly PropertyAccessorInterface $propertyAccessor) - { - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the Hydra class :class exists - */ - public function assertTheHydraClassExist(string $className): void - { - try { - $this->getClassInfo($className); - } catch (\InvalidArgumentException $e) { - throw new ExpectationFailedException(\sprintf('The class "%s" doesn\'t exist.', $className), null, $e); - } - } - - /** - * @Then the Hydra class :class doesn't exist - */ - public function assertTheHydraClassNotExist(string $className): void - { - try { - $this->getClassInfo($className); - } catch (\InvalidArgumentException) { - return; - } - - throw new ExpectationFailedException(\sprintf('The class "%s" exists.', $className)); - } - - /** - * @Then the boolean value of the node :node of the Hydra class :class is true - */ - public function assertBooleanNodeValueIs(string $nodeName, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getClassInfo($className), $nodeName)); - } - - /** - * @Then the value of the node :node of the Hydra class :class is :value - */ - public function assertNodeValueIs(string $nodeName, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getClassInfo($className), $nodeName), - $value - ); - } - - /** - * @Then the boolean value of the node :node of the property :prop of the Hydra class :class is true - */ - public function assertPropertyNodeValueIsTrue(string $nodeName, string $propertyName, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getPropertyInfo($propertyName, $className), $nodeName)); - } - - /** - * @Then the value of the node :node of the property :prop of the Hydra class :class is :value - */ - public function assertPropertyNodeValueIs(string $nodeName, string $propertyName, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getPropertyInfo($propertyName, $className), $nodeName), - $value - ); - } - - /** - * @Then the boolean value of the node :node of the operation :operation of the Hydra class :class is true - */ - public function assertOperationNodeBooleanValueIs(string $nodeName, string $operationMethod, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getOperation($operationMethod, $className), $nodeName)); - } - - /** - * @Then the value of the node :node of the operation :operation of the Hydra class :class is :value - */ - public function assertOperationNodeValueIs(string $nodeName, string $operationMethod, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getOperation($operationMethod, $className), $nodeName), - $value - ); - } - - /** - * @Then the value of the node :node of the operation :operation of the Hydra class :class contains :value - */ - public function assertOperationNodeValueContains(string $nodeName, string $operationMethod, string $className, string $value): void - { - $property = $this->getOperation($operationMethod, $className); - - Assert::assertContains($value, $this->propertyAccessor->getValue($property, $nodeName)); - } - - /** - * @Then :nb operations are available for Hydra class :class - */ - public function assertNbOperationsExist(int $nb, string $className): void - { - Assert::assertEquals($nb, \count($this->getOperations($className))); - } - - /** - * @Then :nb properties are available for Hydra class :class - */ - public function assertNbPropertiesExist(int $nb, string $className): void - { - Assert::assertEquals($nb, \count($this->getProperties($className))); - } - - /** - * @Then :prop property doesn't exist for the Hydra class :class - */ - public function assertPropertyNotExist(string $propertyName, string $className): void - { - try { - $this->getPropertyInfo($propertyName, $className); - } catch (\InvalidArgumentException) { - return; - } - - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" exists.', $propertyName, $className)); - } - - /** - * @Then :prop property is readable for Hydra class :class - */ - public function assertPropertyIsReadable(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:readable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not readable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is not readable for Hydra class :class - */ - public function assertPropertyIsNotReadable(string $propertyName, string $className): void - { - if ($this->getPropertyInfo($propertyName, $className)->{'hydra:readable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is readable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is writable for Hydra class :class - */ - public function assertPropertyIsWritable(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:writeable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not writable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is required for Hydra class :class - */ - public function assertPropertyIsRequired(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:required'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not required', $propertyName, $className)); - } - } - - /** - * @Then :prop property is not required for Hydra class :class - */ - public function assertPropertyIsNotRequired(string $propertyName, string $className): void - { - if ($this->getPropertyInfo($propertyName, $className)->{'hydra:required'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is required', $propertyName, $className)); - } - } - - /** - * Gets information about a property. - * - * @throws \InvalidArgumentException - */ - private function getPropertyInfo(string $propertyName, string $className): \stdClass - { - foreach ($this->getProperties($className) as $property) { - if ($property->{'hydra:title'} === $propertyName) { - return $property; - } - } - - throw new \InvalidArgumentException(\sprintf('Property "%s" of class "%s" doesn\'t exist', $propertyName, $className)); - } - - /** - * Gets an operation by its method name. - * - * @throws \InvalidArgumentException - */ - private function getOperation(string $method, string $className): \stdClass - { - foreach ($this->getOperations($className) as $operation) { - if ($operation->{'hydra:method'} === $method) { - return $operation; - } - } - - throw new \InvalidArgumentException(\sprintf('Operation "%s" of class "%s" doesn\'t exist.', $method, $className)); - } - - /** - * Gets all operations of a given class. - */ - private function getOperations(string $className): array - { - return $this->getClassInfo($className)->{'hydra:supportedOperation'} ?? []; - } - - /** - * Gets all properties of a given class. - */ - private function getProperties(string $className): array - { - return $this->getClassInfo($className)->{'hydra:supportedProperty'} ?? []; - } - - /** - * Gets information about a class. - * - * @throws \InvalidArgumentException - */ - private function getClassInfo(string $className): \stdClass - { - $json = $this->getLastJsonResponse(); - - if (isset($json->{'hydra:supportedClass'})) { - foreach ($json->{'hydra:supportedClass'} as $classData) { - if ($classData->{'hydra:title'} === $className) { - return $classData; - } - } - } - - throw new \InvalidArgumentException(\sprintf('Class %s cannot be found in the vocabulary', $className)); - } - - /** - * Gets the last JSON response. - * - * @throws \RuntimeException - */ - private function getLastJsonResponse(): \stdClass - { - if (null === $decoded = json_decode($this->restContext->getMink()->getSession()->getDriver()->getContent(), null, 512, \JSON_THROW_ON_ERROR)) { - throw new \RuntimeException('JSON response seems to be invalid'); - } - - return $decoded; - } - - /** - * @Then the Hydra context matches the online resource :url - */ - public function assertHydraContextIsCorrect(string $url): void - { - $opts = [ - 'http' => [ - 'method' => 'GET', - 'header' => "User-Agent: Mozilla/5.0\r\n", - ], - ]; - - $context = stream_context_create($opts); - $upstream = json_decode(file_get_contents($url, false, $context)); - $actual = $this->getLastJsonResponse(); - $local = $actual->{'@context'}[0]; - Assert::assertEquals( - $upstream, - $local - ); - } -} diff --git a/tests/Behat/JsonApiContext.php b/tests/Behat/JsonApiContext.php deleted file mode 100644 index 7cd50646c57..00000000000 --- a/tests/Behat/JsonApiContext.php +++ /dev/null @@ -1,209 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CircularReference as CircularReferenceDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CircularReference; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use Behatch\Json\Json; -use Behatch\Json\JsonInspector; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\Persistence\ManagerRegistry; -use Doctrine\Persistence\ObjectManager; -use JsonSchema\Validator; -use PHPUnit\Framework\ExpectationFailedException; - -final class JsonApiContext implements Context -{ - private ?RestContext $restContext = null; - private readonly Validator $validator; - private readonly JsonInspector $inspector; - private readonly string $jsonApiSchemaFile; - private readonly ObjectManager $manager; - - public function __construct(ManagerRegistry $doctrine, string $jsonApiSchemaFile) - { - if (!is_file($jsonApiSchemaFile)) { - throw new \InvalidArgumentException('The JSON API schema doesn\'t exist.'); - } - - $this->validator = new Validator(); - $this->inspector = new JsonInspector('javascript'); - $this->jsonApiSchemaFile = $jsonApiSchemaFile; - $this->manager = $doctrine->getManager(); - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the JSON should be valid according to the JSON API schema - */ - public function theJsonShouldBeValidAccordingToTheJsonApiSchema(): void - { - $json = $this->getJson()->getContent(); - $this->validator->validate($json, (object) ['$ref' => "file://{$this->jsonApiSchemaFile}"]); - - if (!$this->validator->isValid()) { - throw new ExpectationFailedException('The JSON is not valid according to the JSON API schema.'); - } - } - - /** - * @Then the JSON node :node should be an empty array - */ - public function theJsonNodeShouldBeAnEmptyArray(string $node): void - { - $actual = $this->getValueOfNode($node); - if (null !== $actual && [] !== $actual) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual, \JSON_THROW_ON_ERROR))); - } - } - - /** - * @Then the JSON node :node should be a number - */ - public function theJsonNodeShouldBeANumber(string $node): void - { - if (!is_numeric($actual = $this->getValueOfNode($node))) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual, \JSON_THROW_ON_ERROR))); - } - } - - /** - * @Then the JSON node :node should not be an empty string - */ - public function theJsonNodeShouldNotBeAnEmptyString(string $node): void - { - if ('' === $actual = $this->getValueOfNode($node)) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual))); - } - } - - /** - * @Then the JSON node :node should be sorted - * @Then the JSON should be sorted - */ - public function theJsonNodeShouldBeSorted(string $node = ''): void - { - $actual = (array) $this->getValueOfNode($node); - - $expected = $actual; - ksort($expected); - - if ($actual !== $expected) { - throw new ExpectationFailedException(\sprintf('The json node "%s" is not sorted by keys', $node)); - } - } - - /** - * @Given there is a RelatedDummy - */ - public function thereIsARelatedDummy(): void - { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy with no friends'); - - $this->manager->persist($relatedDummy); - $this->manager->flush(); - } - - /** - * @Given there is a DummyFriend - */ - public function thereIsADummyFriend(): void - { - $friend = $this->buildDummyFriend(); - $friend->setName('DummyFriend'); - - $this->manager->persist($friend); - $this->manager->flush(); - } - - /** - * @Given there is a CircularReference - */ - public function thereIsACircularReference(): void - { - $circularReference = $this->buildCircularReference(); - $circularReference->parent = $circularReference; - - $circularReferenceBis = $this->buildCircularReference(); - $circularReferenceBis->parent = $circularReference; - - $circularReference->children->add($circularReference); - $circularReference->children->add($circularReferenceBis); - - $this->manager->persist($circularReference); - $this->manager->persist($circularReferenceBis); - $this->manager->flush(); - } - - private function getValueOfNode(string $node) - { - return $this->inspector->evaluate($this->getJson(), $node); - } - - private function getJson(): Json - { - return new Json($this->getContent()); - } - - private function getContent(): string - { - return $this->restContext->getMink()->getSession()->getDriver()->getContent(); - } - - private function isOrm(): bool - { - return $this->manager instanceof EntityManagerInterface; - } - - private function buildCircularReference(): CircularReference|CircularReferenceDocument - { - return $this->isOrm() ? new CircularReference() : new CircularReferenceDocument(); - } - - private function buildDummyFriend(): DummyFriend|DummyFriendDocument - { - return $this->isOrm() ? new DummyFriend() : new DummyFriendDocument(); - } - - private function buildRelatedDummy(): RelatedDummy|RelatedDummyDocument - { - return $this->isOrm() ? new RelatedDummy() : new RelatedDummyDocument(); - } -} diff --git a/tests/Behat/JsonContext.php b/tests/Behat/JsonContext.php deleted file mode 100644 index 4450465fd08..00000000000 --- a/tests/Behat/JsonContext.php +++ /dev/null @@ -1,112 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Mink\Exception\ExpectationException; -use Behatch\Context\JsonContext as BaseJsonContext; -use Behatch\HttpCall\HttpCallResultPool; -use Behatch\Json\Json; -use PHPUnit\Framework\Assert; - -final class JsonContext extends BaseJsonContext -{ - public function __construct(HttpCallResultPool $httpCallResultPool) - { - parent::__construct($httpCallResultPool); - } - - /** - * @Then the JSON node :node should contain: - */ - public function theJsonNodeShouldContainContent(string $node, PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception $e) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver(), $e); - } - - $actualContent = $this->inspector->evaluate($actual, $node); - - if (!is_iterable($actualContent)) { - throw new ExpectationException(\sprintf("The JSON is equal to:\n%s", json_encode($actualContent, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT)), $this->getSession()->getDriver()); - } - - foreach ($actualContent as $itemContent) { - try { - $this->assertEquals($expected->getContent(), $itemContent, ' '); - } catch (ExpectationException) { - continue; - } - - return; - } - - throw new ExpectationException("The JSON node \"{$node}\" does not contain the expected content.", $this->getSession()->getDriver()); - } - - /** - * @Then the JSON node :node should be equal to: - */ - public function theJsonNodeShouldBeEqualToContent(string $node, PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception $e) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver(), $e); - } - - $actualContent = $this->inspector->evaluate($actual, $node); - - $this->assertEquals( - $expected->getContent(), - $actualContent, - \sprintf("The JSON node \"%s\" is equal to:\n%s", $node, json_encode($actualContent, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT)) - ); - } - - public function theJsonShouldBeEqualTo(PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver()); - } - - $this->assertEquals( - $expected->getContent(), - $actual->getContent(), - "The JSON is equal to:\n{$actual->encode()}" - ); - } - - /** - * @Then /^the JSON should be a superset of:$/ - */ - public function theJsonIsASupersetOf(PyStringNode $content): void - { - $array = json_decode($this->httpCallResultPool->getResult()->getValue(), true, 512, \JSON_THROW_ON_ERROR); - $subset = json_decode($content->getRaw(), true, 512, \JSON_THROW_ON_ERROR); - - method_exists(Assert::class, 'assertArraySubset') ? Assert::assertArraySubset($subset, $array) : ApiTestCase::assertArraySubset($subset, $array); - } -} diff --git a/tests/Behat/JsonHalContext.php b/tests/Behat/JsonHalContext.php deleted file mode 100644 index 91cff357660..00000000000 --- a/tests/Behat/JsonHalContext.php +++ /dev/null @@ -1,80 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use Behatch\Json\Json; -use JsonSchema\Validator; -use PHPUnit\Framework\ExpectationFailedException; - -final class JsonHalContext implements Context -{ - private ?RestContext $restContext = null; - private readonly Validator $validator; - private readonly string $schemaFile; - - public function __construct(string $schemaFile) - { - if (!is_file($schemaFile)) { - throw new \InvalidArgumentException('The JSON HAL schema doesn\'t exist.'); - } - - $this->validator = new Validator(); - $this->schemaFile = $schemaFile; - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the JSON should be valid according to the JSON HAL schema - */ - public function theJsonShouldBeValidAccordingToTheJsonHALSchema(): void - { - $json = $this->getJson()->getContent(); - $this->validator->validate($json, (object) ['$ref' => "file://{$this->schemaFile}"]); - - if (!$this->validator->isValid()) { - throw new ExpectationFailedException('The JSON is not valid according to the HAL+JSON schema.'); - } - } - - private function getJson(): Json - { - return new Json($this->getContent()); - } - - private function getContent(): string - { - return $this->restContext->getMink()->getSession()->getDriver()->getContent(); - } -} diff --git a/tests/Behat/MercureContext.php b/tests/Behat/MercureContext.php deleted file mode 100644 index 2dbd68f8775..00000000000 --- a/tests/Behat/MercureContext.php +++ /dev/null @@ -1,144 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Mercure\TestHub; -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use PHPUnit\Framework\Assert; -use Psr\Container\ContainerInterface; -use Symfony\Component\Mercure\Update; - -/** - * Context for Mercure. - * - * @author Alan Poulain - */ -final class MercureContext implements Context -{ - public function __construct(private readonly ContainerInterface $driverContainer) - { - } - - /** - * @Then :number Mercure updates should have been sent - * @Then :number Mercure update should have been sent - */ - public function mercureUpdatesShouldHaveBeenSent(int $number): void - { - $updateHandler = $this->getMercureTestHub(); - $total = \count($updateHandler->getUpdates()); - - if (0 === $total) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - Assert::assertEquals($number, $total, \sprintf('Expected %d Mercure updates to be sent, got %d.', $number, $total)); - } - - /** - * @Then the first Mercure update should have topics: - * @Then the Mercure update should have topics: - */ - public function firstMercureUpdateShouldHaveTopics(TableNode $table): void - { - $this->mercureUpdateShouldHaveTopics(1, $table); - } - - /** - * @Then the first Mercure update should have data: - * @Then the Mercure update should have data: - */ - public function firstMercureUpdateShouldHaveData(PyStringNode $data): void - { - $this->mercureUpdateShouldHaveData(1, $data); - } - - /** - * @Then the Mercure update number :index should have topics: - */ - public function mercureUpdateShouldHaveTopics(int $index, TableNode $table): void - { - $updateHandler = $this->getMercureTestHub(); - $updates = $updateHandler->getUpdates(); - - if (0 === \count($updates)) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - if (!isset($updates[$index - 1])) { - throw new \RuntimeException(\sprintf('Mercure update #%d does not exist.', $index)); - } - /** @var Update $update */ - $update = $updates[$index - 1]; - Assert::assertEquals(array_keys($table->getRowsHash()), array_values($update->getTopics())); - } - - /** - * @Then the Mercure update number :index should have data: - */ - public function mercureUpdateShouldHaveData(int $index, PyStringNode $data): void - { - $updateHandler = $this->getMercureTestHub(); - $updates = $updateHandler->getUpdates(); - - if (0 === \count($updates)) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - if (!isset($updates[$index - 1])) { - throw new \RuntimeException(\sprintf('Mercure update #%d does not exist.', $index)); - } - /** @var Update $update */ - $update = $updates[$index - 1]; - Assert::assertJsonStringEqualsJsonString($data->getRaw(), $update->getData()); - } - - /** - * @Then the following Mercure update with topics :topics should have been sent: - */ - public function theFollowingMercureUpdateShouldHaveBeenSent(string $topics, PyStringNode $update): void - { - $topics = explode(',', $topics); - $update = json_decode($update->getRaw(), true, 512, \JSON_THROW_ON_ERROR); - - $updateHandler = $this->getMercureTestHub(); - foreach ($updateHandler->getUpdates() as $sentUpdate) { - $toMatchTopics = \count($topics); - foreach ($sentUpdate->getTopics() as $sentTopic) { - foreach ($topics as $topic) { - if (preg_match("@$topic@", (string) $sentTopic)) { - --$toMatchTopics; - } - } - } - - if ($toMatchTopics > 0) { - continue; - } - - if ($sentUpdate->getData() === json_encode($update, \JSON_THROW_ON_ERROR)) { - return; - } - } - - throw new \RuntimeException('Mercure update has not been sent.'); - } - - private function getMercureTestHub(): TestHub - { - return $this->driverContainer->get('mercure.hub.default.test_hub'); - } -} diff --git a/tests/Behat/XmlContext.php b/tests/Behat/XmlContext.php deleted file mode 100644 index 33a811470d2..00000000000 --- a/tests/Behat/XmlContext.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Behat; - -use Behat\Gherkin\Node\PyStringNode; -use Behatch\Context\XmlContext as BaseXmlContext; -use Symfony\Component\Serializer\Encoder\XmlEncoder; - -final class XmlContext extends BaseXmlContext -{ - private readonly XmlEncoder $xmlEncoder; - - public function __construct() - { - $this->xmlEncoder = new XmlEncoder(); - } - - /** - * @Then the XML should be equal to: - */ - public function theXmlShouldBeEqualTo(PyStringNode $content): void - { - $expected = $this->xmlEncoder->decode((string) $content, 'xml'); - $actual = $this->xmlEncoder->decode($actualXml = $this->getSession()->getPage()->getContent(), 'xml'); - - $this->assertEquals( - $expected, - $actual, - "The XML is equal to:\n{$actualXml}" - ); - } -} diff --git a/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php index 62a18c4ab6c..9e9e6b893f6 100644 --- a/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php +++ b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php @@ -20,7 +20,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::ABS_URL)] -#[ApiResource(uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] +#[ApiResource(shortName: 'AbsoluteUrlDummySubresource', uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] #[ODM\Document] class AbsoluteUrlDummy { diff --git a/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php index 84bc5353cc5..9b8e8736943 100644 --- a/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php +++ b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php @@ -20,7 +20,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::NET_PATH)] -#[ApiResource(uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] +#[ApiResource(shortName: 'NetworkPathDummySubresource', uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] #[ODM\Document] class NetworkPathDummy { diff --git a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php index a632d81dc83..4eeb455e2d7 100644 --- a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php +++ b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php @@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::ABS_URL)] -#[ApiResource(uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] +#[ApiResource(shortName: 'AbsoluteUrlDummySubresource', uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] #[ORM\Entity] class AbsoluteUrlDummy { diff --git a/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php b/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php index 7089cdf2a70..09e22711d40 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php +++ b/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php @@ -28,8 +28,8 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_products/{id}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/offers{._format}', shortName: 'DummyAggregateOfferByProduct', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers{._format}', shortName: 'DummyAggregateOfferByRelatedProduct', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyAggregateOffer { diff --git a/tests/Fixtures/TestBundle/Entity/DummyOffer.php b/tests/Fixtures/TestBundle/Entity/DummyOffer.php index 47c9c42142d..2988a075348 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyOffer.php +++ b/tests/Fixtures/TestBundle/Entity/DummyOffer.php @@ -26,9 +26,9 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_aggregate_offers/{id}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/offers/{offers}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers/{offers}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_aggregate_offers/{id}/offers{._format}', shortName: 'DummyOfferByAggregate', uriVariables: ['id' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/offers/{offers}/offers{._format}', shortName: 'DummyOfferByProductOffer', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers/{offers}/offers{._format}', shortName: 'DummyOfferByRelatedProductOffer', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyOffer { diff --git a/tests/Fixtures/TestBundle/Entity/DummyProduct.php b/tests/Fixtures/TestBundle/Entity/DummyProduct.php index d6e428f8a02..2e83f1b06a9 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyProduct.php +++ b/tests/Fixtures/TestBundle/Entity/DummyProduct.php @@ -28,7 +28,7 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products{._format}', shortName: 'DummyProductRelatedProducts', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyProduct { diff --git a/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php b/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php index 224206c15bf..2a850595ec3 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php +++ b/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php @@ -19,6 +19,7 @@ #[Post] #[ApiResource( + shortName: 'DummyResourceWithComplexConstructorByCompany', uriTemplate: '/companies/{companyId}/employees/{id}', uriVariables: [ 'companyId' => ['from_class' => Company::class, 'to_property' => 'company'], diff --git a/tests/Fixtures/TestBundle/Entity/Greeting.php b/tests/Fixtures/TestBundle/Entity/Greeting.php index d74e8217b55..7cad16e25a7 100644 --- a/tests/Fixtures/TestBundle/Entity/Greeting.php +++ b/tests/Fixtures/TestBundle/Entity/Greeting.php @@ -19,7 +19,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource] -#[ApiResource(uriTemplate: '/people/{id}/sent_greetings{._format}', uriVariables: ['id' => new Link(fromClass: Person::class, identifiers: ['id'], toProperty: 'sender')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/people/{id}/sent_greetings{._format}', shortName: 'GreetingBySender', uriVariables: ['id' => new Link(fromClass: Person::class, identifiers: ['id'], toProperty: 'sender')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class Greeting { diff --git a/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php index 098750dc0dc..1ac8ec4d55b 100644 --- a/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php +++ b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php @@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::NET_PATH)] -#[ApiResource(uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] +#[ApiResource(shortName: 'NetworkPathDummySubresource', uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] #[ORM\Entity] class NetworkPathDummy { diff --git a/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php b/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php new file mode 100644 index 00000000000..d6edb96561b --- /dev/null +++ b/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\MessengerHandler\Document; + +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RPC; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +#[AsMessageHandler] +class RPCHandler +{ + public function __invoke(RPC $data): void + { + } +} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index e26c8bc3eb7..6cb1bb86951 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use ApiPlatform\Symfony\Bundle\ApiPlatformBundle; -use ApiPlatform\Tests\Behat\DoctrineContext; use ApiPlatform\Tests\Fixtures\TestBundle\Document\User as UserDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; @@ -27,7 +26,6 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\MongoDBBundle\Command\TailCursorDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; -use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle; use Symfony\AI\McpBundle\McpBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; @@ -63,7 +61,6 @@ public function __construct(string $environment, bool $debug, ?bool $genIdDefaul { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; $this->genIdDefault = $genIdDefault ?? $_SERVER['GEN_ID_DEFAULT'] ?? null; } @@ -81,10 +78,6 @@ public function registerBundles(): array new MakerBundle(), ]; - if (null === ($_ENV['APP_PHPUNIT'] ?? null) && class_exists(FriendsOfBehatSymfonyExtensionBundle::class)) { - $bundles[] = new FriendsOfBehatSymfonyExtensionBundle(); - } - if (extension_loaded('mongodb') && class_exists(DoctrineMongoDBBundle::class)) { $bundles[] = new DoctrineMongoDBBundle(); } @@ -120,11 +113,6 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $loader->load(__DIR__."/config/config_{$this->getEnvironment()}.yml"); - if (interface_exists(Behat\Behat\Context\Context::class) && class_exists(DoctrineContext::class)) { - $loader->load(__DIR__.('mongodb' === $this->getEnvironment() ? '/config/config_behat_mongodb.yml' : '/config/config_behat_orm.yml')); - $c->getDefinition(DoctrineContext::class)->setArgument('$passwordHasher', class_exists(NativePasswordHasher::class) ? 'security.user_password_encoder' : 'security.user_password_hasher'); - } - $messengerConfig = [ 'default_bus' => 'messenger.bus.default', 'buses' => [ diff --git a/tests/Fixtures/app/bootstrap.php b/tests/Fixtures/app/bootstrap.php index 10db0977595..d268c9f75e7 100644 --- a/tests/Fixtures/app/bootstrap.php +++ b/tests/Fixtures/app/bootstrap.php @@ -23,4 +23,11 @@ require __DIR__.'/AppKernel.php'; require __DIR__.'/DefaultParametersAppKernel.php'; +if (!is_file($resourcesFile = __DIR__.'/var/resources.php')) { + if (!is_dir(dirname($resourcesFile))) { + mkdir(dirname($resourcesFile), 0777, true); + } + file_put_contents($resourcesFile, ' + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedBoolean as ConvertedBooleanDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class BooleanFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + EmbeddedDummy::class, + RelatedDummy::class, + ConvertedBoolean::class, + ]; + } + + #[TestWith(['true', 15, ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['1', 15, ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['false', 10, ['/dummies/16', '/dummies/17', '/dummies/18']])] + #[TestWith(['0', 10, ['/dummies/16', '/dummies/17', '/dummies/18']])] + public function testFilterDummiesByBoolean(string $value, int $expectedTotal, array $expectedIds): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 15, true); + $this->createDummies($resource, 10, false); + + $response = self::createClient()->request('GET', '/dummies?dummyBoolean='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/Dummy', $data['@context']); + $this->assertSame('/dummies', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + $this->assertSame('hydra:PartialCollectionView', $data['hydra:view']['@type']); + $this->assertStringContainsString('dummyBoolean='.$value, $data['hydra:view']['@id']); + } + + #[TestWith(['true', 15, ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3']])] + #[TestWith(['1', 15, ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3']])] + #[TestWith(['false', 10, ['/embedded_dummies/16', '/embedded_dummies/17', '/embedded_dummies/18']])] + #[TestWith(['0', 10, ['/embedded_dummies/16', '/embedded_dummies/17', '/embedded_dummies/18']])] + public function testFilterEmbeddedDummiesByEmbeddedBoolean(string $value, int $expectedTotal, array $expectedIds): void + { + $embeddedDummyClass = $this->embeddedDummyClass(); + $embeddableDummyClass = $this->embeddableDummyClass(); + $this->recreateSchema([$embeddedDummyClass]); + $this->createEmbeddedDummies($embeddedDummyClass, $embeddableDummyClass, 15, true); + $this->createEmbeddedDummies($embeddedDummyClass, $embeddableDummyClass, 10, false); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyBoolean='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/EmbeddedDummy', $data['@context']); + $this->assertSame('/embedded_dummies', $data['@id']); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testFilterEmbeddedDummiesByRelatedDummyEmbeddedBoolean(): void + { + $embeddedDummyClass = $this->embeddedDummyClass(); + $embeddableDummyClass = $this->embeddableDummyClass(); + $relatedDummyClass = $this->relatedDummyClass(); + $this->recreateSchema([$embeddedDummyClass, $relatedDummyClass]); + $this->createEmbeddedDummiesWithRelatedDummy($embeddedDummyClass, $embeddableDummyClass, $relatedDummyClass, 15, true); + $this->createEmbeddedDummiesWithRelatedDummy($embeddedDummyClass, $embeddableDummyClass, $relatedDummyClass, 10, false); + + $response = self::createClient()->request('GET', '/embedded_dummies?relatedDummy.embeddedDummy.dummyBoolean=true', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(15, $data['hydra:totalItems']); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + #[TestWith(['0'])] + #[TestWith(['1'])] + public function testCollectionIgnoresUnknownBooleanFilter(string $value): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 15, true); + $this->createDummies($resource, 10, false); + + $response = self::createClient()->request('GET', '/dummies?unknown='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(25, $response->toArray()['hydra:totalItems']); + } + + public function testFilterCollectionUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedBooleanDocument::class : ConvertedBoolean::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = (bool) ($i % 2); + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_booleans?name_converted=false', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_booleans/2', '/converted_booleans/4'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedBoolean', $member['@type']); + $this->assertFalse($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function embeddedDummyClass(): string + { + return $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + } + + /** + * @return class-string + */ + private function embeddableDummyClass(): string + { + return $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb, bool $bool): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyBoolean($bool); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + */ + private function createEmbeddedDummies(string $embeddedClass, string $embeddableClass, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Embedded Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embedded Dummy #'.$i); + $embeddable->setDummyBoolean($bool); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + * @param class-string $relatedClass + */ + private function createEmbeddedDummiesWithRelatedDummy(string $embeddedClass, string $embeddableClass, string $relatedClass, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Embedded Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embedded Dummy #'.$i); + $embeddable->setDummyBoolean($bool); + + $related = new $relatedClass(); + $related->setEmbeddedDummy($embeddable); + + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/DateFilterTest.php b/tests/Functional/Doctrine/DateFilterTest.php new file mode 100644 index 00000000000..eaceb789db6 --- /dev/null +++ b/tests/Functional/Doctrine/DateFilterTest.php @@ -0,0 +1,385 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedDate as ConvertedDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyImmutableDate as DummyImmutableDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class DateFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + EmbeddedDummy::class, + DummyDate::class, + DummyImmutableDate::class, + ConvertedDate::class, + ]; + } + + #[TestWith(['dummyDate[after]=2015-04-28', 2])] + #[TestWith(['dummyDate[before]=2015-04-05', 5])] + #[TestWith(['dummyDate[after]=2015-04-28T00:00:00%2B00:00', 2])] + #[TestWith(['dummyDate[before]=2015-04-05Z', 5])] + #[TestWith(['dummyDate[before]=2015-04-05&dummyDate[after]=2015-04-05', 1])] + #[TestWith(['dummyDate[after]=2015-04-05&dummyDate[before]=2015-04-05', 1])] + #[TestWith(['dummyDate[after]=2015-04-06&dummyDate[before]=2015-04-04', 0])] + public function testDummyDateFilter(string $query, int $expectedTotal): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithDate($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame($expectedTotal, $response->toArray()['hydra:totalItems']); + } + + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28', 3])] + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28&relatedDummy_dummyDate[after]=2015-04-28', 3])] + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28T00:00:00%2B00:00', 3])] + public function testAssociationDateFilter(string $query, int $expectedTotal): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithDateAndRelatedDummy($resource, $relatedResource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame($expectedTotal, $response->toArray()['hydra:totalItems']); + } + + public function testAssociationDateFilterWithEmptyResultSet(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithDateAndRelatedDummy($resource, $relatedResource, 2); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(0, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByDateThatIsNotDatetime(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 30); + + $response = self::createClient()->request('GET', '/dummy_dates?dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByDateIncludeNullAfter(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullAfter'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullAfter[after]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullAfter']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullAfter[before]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullAfter']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][1]['dateIncludeNullAfter']); + } + + public function testCollectionFilteredByDateIncludeNullBefore(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullBefore'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBefore[before]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBefore']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBefore']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBefore[after]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBefore']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][1]['dateIncludeNullBefore']); + } + + public function testCollectionFilteredByDateIncludeNullBeforeAndAfter(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullBeforeAndAfter'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBeforeAndAfter[before]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBeforeAndAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBeforeAndAfter']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBeforeAndAfter[after]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBeforeAndAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBeforeAndAfter']); + } + + public function testCollectionFilteredByImmutableDate(): void + { + $resource = $this->isMongoDB() ? DummyImmutableDateDocument::class : DummyImmutableDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $dummy = new $resource(); + $dummy->dummyDate = new \DateTimeImmutable(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_immutable_dates?dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByEmbeddedDate(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 29; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embeddable #'.$i); + $embeddable->setDummyDate($date); + + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + if (29 !== $i) { + $dummy->setDummyDate($date); + } + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/embedded_dummies/28', '/embedded_dummies/29'], $ids); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedDateDocument::class : ConvertedDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $entity = new $resource(); + $entity->nameConverted = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_dates?name_converted[strictly_after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_dates/29', '/converted_dates/30'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedDate', $member['@type']); + $this->assertIsString($member['name_converted']); + } + + $this->assertSame('hydra:IriTemplate', $data['hydra:search']['@type']); + $this->assertSame('BasicRepresentation', $data['hydra:search']['hydra:variableRepresentation']); + $variables = array_map(static fn (array $m): string => $m['variable'], $data['hydra:search']['hydra:mapping']); + sort($variables); + $this->assertSame([ + 'name_converted[after]', + 'name_converted[before]', + 'name_converted[strictly_after]', + 'name_converted[strictly_before]', + ], $variables); + foreach ($data['hydra:search']['hydra:mapping'] as $mapping) { + $this->assertSame('IriTemplateMapping', $mapping['@type']); + $this->assertSame('name_converted', $mapping['property']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @return class-string + */ + private function dummyDateClass(): string + { + return $this->isMongoDB() ? DummyDateDocument::class : DummyDate::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithDate(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + if ($nb !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesWithDateAndRelatedDummy(string $resource, string $relatedResource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setDummyDate($date); + + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setRelatedDummy($relatedDummy); + if ($nb !== $i) { + $dummy->setDummyDate($date); + } + $manager->persist($relatedDummy); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + */ + private function createDummyDates(string $resource, int $nb, ?string $nullableProperty = null): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $dummy = new $resource(); + $dummy->dummyDate = $date; + if ($nullableProperty) { + $dummy->{$nullableProperty} = 0 === $i % 3 ? null : $date; + } + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/EagerLoadingTest.php b/tests/Functional/Doctrine/EagerLoadingTest.php new file mode 100644 index 00000000000..d6f7168b8a0 --- /dev/null +++ b/tests/Functional/Doctrine/EagerLoadingTest.php @@ -0,0 +1,300 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPassenger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EagerLoadingTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyFriend::class, + RelatedToDummyFriend::class, + DummyTravel::class, + DummyCar::class, + DummyPassenger::class, + ThirdLevel::class, + FourthLevel::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + if ($this->isMongoDB()) { + $this->markTestSkipped('Eager loading is ORM only.'); + } + } + + public function testEagerLoadingForARelation(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a1, relatedToDummyFriend_a3, fourthLevel_a2, dummyFriend_a4 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + LEFT JOIN o.thirdLevel thirdLevel_a1 + LEFT JOIN thirdLevel_a1.fourthLevel fourthLevel_a2 + LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a3 + LEFT JOIN relatedToDummyFriend_a3.dummyFriend dummyFriend_a4 +WHERE o.id = :id_p1 +DQL); + } + + public function testEagerLoadingForTheSearchFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class, + ]); + $this->createDummyWithFourthLevelRelation(); + + self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.level=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o + INNER JOIN o.relatedDummy relatedDummy_a1 + INNER JOIN relatedDummy_a1.thirdLevel thirdLevel_a2 +WHERE o IN( + SELECT o_a3 + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o_a3 + INNER JOIN o_a3.relatedDummy relatedDummy_a4 + INNER JOIN relatedDummy_a4.thirdLevel thirdLevel_a5 + WHERE thirdLevel_a5.level = :level_p1 + ) +ORDER BY o.id ASC +DQL); + } + + public function testEagerLoadingForARelationAndSearchFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies?relatedToDummyFriend.dummyFriend=2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, fourthLevel_a5, dummyFriend_a6 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1 + LEFT JOIN o.thirdLevel thirdLevel_a4 + LEFT JOIN thirdLevel_a4.fourthLevel fourthLevel_a5 + INNER JOIN relatedToDummyFriend_a1.dummyFriend dummyFriend_a6 +WHERE o IN( + SELECT o_a2 + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o_a2 + INNER JOIN o_a2.relatedToDummyFriend relatedToDummyFriend_a3 + WHERE relatedToDummyFriend_a3.dummyFriend = :dummyFriend_p1 + ) +ORDER BY o.id ASC +DQL); + } + + public function testEagerLoadingForARelationAndPropertyFilterWithMultipleRelations(): void + { + $this->recreateSchema([ + DummyTravel::class, DummyCar::class, DummyPassenger::class, + ]); + $this->createDummyTravel(); + + $response = self::createClient()->request( + 'GET', + '/dummy_travels/1?properties[]=confirmed&properties[car][]=brand&properties[passenger][]=nickname', + ['headers' => ['Accept' => 'application/ld+json']] + ); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertTrue($data['confirmed']); + $this->assertSame('DummyBrand', $data['car']['carBrand']); + $this->assertSame('Tom', $data['passenger']['nickname']); + $this->assertDqlEquals(<<<'DQL' +SELECT o, car_a1, passenger_a2 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel o + LEFT JOIN o.car car_a1 + LEFT JOIN o.passenger passenger_a2 +WHERE o.id = :id_p1 +DQL); + } + + public function testEagerLoadingForARelationWithComplexSubQueryFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies?complex_sub_query_filter=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a3, relatedToDummyFriend_a5, fourthLevel_a4, dummyFriend_a6 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + LEFT JOIN o.thirdLevel thirdLevel_a3 + LEFT JOIN thirdLevel_a3.fourthLevel fourthLevel_a4 + LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a5 + LEFT JOIN relatedToDummyFriend_a5.dummyFriend dummyFriend_a6 +WHERE o.id IN ( + SELECT related_dummy_a1.id + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy related_dummy_a1 + INNER JOIN related_dummy_a1.relatedToDummyFriend related_to_dummy_friend_a2 + WITH related_to_dummy_friend_a2.name = :name_p1 + ) +ORDER BY o.id ASC +DQL); + } + + private function createRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + + $manager->persist($relation); + } + + $relatedDummy2 = new RelatedDummy(); + $relatedDummy2->setName('RelatedDummy without friends'); + $manager->persist($relatedDummy2); + $manager->flush(); + $manager->clear(); + } + + private function createDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + $manager->clear(); + } + + private function createDummyTravel(): void + { + $manager = $this->getManager(); + + $car = new DummyCar(); + $car->setName('model x'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + + $passenger = new DummyPassenger(); + $passenger->nickname = 'Tom'; + $manager->persist($passenger); + + $travel = new DummyTravel(); + $travel->car = $car; + $travel->passenger = $passenger; + $travel->confirmed = true; + $manager->persist($travel); + + $manager->flush(); + $manager->clear(); + } + + private function assertDqlEquals(string $expected): void + { + $actual = EntityManager::$dql; + $expected = preg_replace('/\(\R */', '(', $expected); + $expected = preg_replace('/\R *\)/', ')', $expected); + $expected = preg_replace('/\R */', ' ', $expected); + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Functional/Doctrine/ExistsFilterTest.php b/tests/Functional/Doctrine/ExistsFilterTest.php new file mode 100644 index 00000000000..04bdb592cb9 --- /dev/null +++ b/tests/Functional/Doctrine/ExistsFilterTest.php @@ -0,0 +1,201 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedString as ConvertedStringDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedString; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ExistsFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ConvertedString::class, + ]; + } + + public function testCollectionWhereScalarPropertyDoesNotExist(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithBoolean($resource, 15, true); + + $response = self::createClient()->request('GET', '/dummies?exists[dummyBoolean]=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame(0, $data['hydra:totalItems']); + $this->assertSame([], $data['hydra:member']); + } + + public function testCollectionWhereScalarPropertyDoesExist(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithBoolean($resource, 15, true); + + $response = self::createClient()->request('GET', '/dummies?exists[dummyBoolean]=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(15, $data['hydra:totalItems']); + $this->assertCount(3, $data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/dummies/(1|2|3)$#', $member['@id']); + } + } + + public function testCollectionWithEmptyRelationCollection(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource, $this->relatedDummyClass()]); + $this->createDummiesWithRelated($resource, 3, 0); + $this->createDummiesWithRelated($resource, 2, 3); + + $response = self::createClient()->request('GET', '/dummies?exists[relatedDummies]=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(3, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/1', '/dummies/2', '/dummies/3'], $ids); + } + + public function testCollectionWithNonEmptyRelationCollection(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource, $this->relatedDummyClass()]); + $this->createDummiesWithRelated($resource, 3, 0); + $this->createDummiesWithRelated($resource, 2, 3); + + $response = self::createClient()->request('GET', '/dummies?exists[relatedDummies]=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/4', '/dummies/5'], $ids); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedStringDocument::class : ConvertedString::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 4; ++$i) { + $entity = new $resource(); + $entity->nameConverted = ($i % 2) ? "name#$i" : null; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_strings?exists[name_converted]=true', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_strings/1', '/converted_strings/3'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedString', $member['@type']); + $this->assertMatchesRegularExpression('/^name#(1|3)$/', $member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithBoolean(string $resource, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummyBoolean($bool); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + */ + private function createDummiesWithRelated(string $resource, int $nb, int $nbRelated): void + { + $manager = $this->getManager(); + $relatedDummyClass = $this->relatedDummyClass(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + for ($j = 1; $j <= $nbRelated; ++$j) { + $relatedDummy = new $relatedDummyClass(); + $relatedDummy->setName('RelatedDummy'.$j.$i); + $relatedDummy->setAge((int) ($j.$i)); + $manager->persist($relatedDummy); + $dummy->addRelatedDummy($relatedDummy); + } + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/LinkHandlerTest.php b/tests/Functional/Doctrine/LinkHandlerTest.php new file mode 100644 index 00000000000..8f52ba454da --- /dev/null +++ b/tests/Functional/Doctrine/LinkHandlerTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\LinkHandledDummy as LinkHandledDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LinkHandledDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class LinkHandlerTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [LinkHandledDummy::class]; + } + + public function testGetCollectionFiltersBySlugViaLinksHandler(): void + { + $resource = $this->isMongoDB() ? LinkHandledDummyDocument::class : LinkHandledDummy::class; + $this->recreateSchema([$resource]); + + $manager = $this->getManager(); + foreach (['foo', 'bar', 'baz', 'foz'] as $slug) { + $manager->persist(new $resource($slug)); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/link_handled_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testGetItemReturnsSlug(): void + { + $resource = $this->isMongoDB() ? LinkHandledDummyDocument::class : LinkHandledDummy::class; + $this->recreateSchema([$resource]); + + $manager = $this->getManager(); + foreach (['foo', 'bar', 'baz', 'foz'] as $slug) { + $manager->persist(new $resource($slug)); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/link_handled_dummies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('foo', $response->toArray()['slug']); + } +} diff --git a/tests/Functional/Doctrine/MappedSuperclassPutTest.php b/tests/Functional/Doctrine/MappedSuperclassPutTest.php new file mode 100644 index 00000000000..80088c63df6 --- /dev/null +++ b/tests/Functional/Doctrine/MappedSuperclassPutTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMappedSubclass; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MappedSuperclassPutTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyMappedSubclass::class]; + } + + public function testStandardPutOnEntityInheritedFromMappedSuperclass(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema([DummyMappedSubclass::class]); + + $manager = $this->getManager(); + $manager->persist(new DummyMappedSubclass()); + $manager->flush(); + + $response = self::createClient()->request('PUT', '/dummy_mapped_subclasses/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'updated value'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/DummyMappedSubclass', + '@id' => '/dummy_mapped_subclasses/1', + '@type' => 'DummyMappedSubclass', + 'id' => 1, + 'foo' => 'updated value', + ]); + } +} diff --git a/tests/Functional/Doctrine/MultipleFilterTest.php b/tests/Functional/Doctrine/MultipleFilterTest.php new file mode 100644 index 00000000000..787615cebf3 --- /dev/null +++ b/tests/Functional/Doctrine/MultipleFilterTest.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MultipleFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + public function testCollectionFilteredByDateAndBoolean(): void + { + $resource = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30, true); + $this->createDummies($resource, 20, false); + + $response = self::createClient()->request('GET', '/dummies?dummyDate[after]=2015-04-28&dummyBoolean=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + + $data = $response->toArray(); + $this->assertSame('/contexts/Dummy', $data['@context']); + $this->assertSame('/dummies', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertCount(2, $data['hydra:member']); + + $ids = array_map(static fn (array $item): string => $item['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/28', '/dummies/29'], $ids); + + $this->assertSame('hydra:PartialCollectionView', $data['hydra:view']['@type']); + $this->assertSame('/dummies?dummyBoolean=1&dummyDate%5Bafter%5D=2015-04-28', $data['hydra:view']['@id']); + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb, bool $bool): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyBoolean($bool); + + if ($nb !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + + $manager->persist($dummy); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/NumericFilterTest.php b/tests/Functional/Doctrine/NumericFilterTest.php new file mode 100644 index 00000000000..b120b28ea68 --- /dev/null +++ b/tests/Functional/Doctrine/NumericFilterTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NumericFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class, ConvertedInteger::class]; + } + + public function testCollectionByDummyPrice(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice=9.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(3, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/1', '/dummies/5', '/dummies/9'], $ids); + } + + public function testCollectionByMultipleDummyPrice(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice[]=9.99&dummyPrice[]=12.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(6, $data['hydra:totalItems']); + $this->assertCount(3, $data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/dummies/(1|2|5|6|9|10)$#', $member['@id']); + } + } + + public function testCollectionByNonNumericDummyPriceIsIgnored(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice=marty', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(20, $data['hydra:totalItems']); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?name_converted[]=2&name_converted[]=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_integers/2', '/converted_integers/3'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithPrice(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $prices = ['9.99', '12.99', '15.99', '19.99']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyPrice($prices[($i - 1) % 4]); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/OrderFilterTest.php b/tests/Functional/Doctrine/OrderFilterTest.php new file mode 100644 index 00000000000..a4f6c2473c8 --- /dev/null +++ b/tests/Functional/Doctrine/OrderFilterTest.php @@ -0,0 +1,314 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class OrderFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + EmbeddedDummy::class, + ConvertedInteger::class, + ]; + } + + #[TestWith(['order[id]=asc', ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['order[id]=desc', ['/dummies/30', '/dummies/29', '/dummies/28']])] + #[TestWith(['order[name]=asc', ['/dummies/1', '/dummies/10', '/dummies/11']])] + #[TestWith(['order[name]=desc', ['/dummies/9', '/dummies/8', '/dummies/7']])] + public function testOrderDummies(string $query, array $expectedIds): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testOrderByMultipleProperties(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?order[name]=desc&order[id]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/39', '/dummies/9', '/dummies/38'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByAssociation(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithRelatedDummy($resource, $relatedResource, 30); + + $response = self::createClient()->request('GET', '/dummies?order[relatedDummy]=asc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByEmbedded(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('EmbeddedDummy #'.$i); + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/embedded_dummies?order[embeddedDummy]=asc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByEmbeddedStringWithoutValueReturns422(): void + { + $resource = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $this->recreateSchema([$resource]); + + self::createClient()->request('GET', '/embedded_dummies?order[embeddedDummy.dummyName]', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(422); + } + + #[TestWith(['order[alias]=asc'])] + #[TestWith(['order[alias]=desc'])] + #[TestWith(['order[unknown]=asc'])] + #[TestWith(['order[unknown]=desc'])] + public function testOrderByUnsupportedProperty(string $query): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByRelatedProperty(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithRelatedDummy($resource, $relatedResource, 2); + + $response = self::createClient()->request('GET', '/dummies?order[relatedDummy.name]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/2', '/dummies/1'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?order[name_converted]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/converted_integers/3', '/converted_integers/2', '/converted_integers/1'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + + $this->assertSame('hydra:IriTemplate', $data['hydra:search']['@type']); + $this->assertSame('BasicRepresentation', $data['hydra:search']['hydra:variableRepresentation']); + $this->assertStringMatchesFormat('/converted_integers{?%a}', $data['hydra:search']['hydra:template']); + $variables = array_map(static fn (array $m): string => $m['variable'], $data['hydra:search']['hydra:mapping']); + sort($variables); + $this->assertSame([ + 'name_converted', + 'name_converted[]', + 'name_converted[between]', + 'name_converted[gt]', + 'name_converted[gte]', + 'name_converted[lt]', + 'name_converted[lte]', + 'order[name_converted]', + ], $variables); + foreach ($data['hydra:search']['hydra:mapping'] as $mapping) { + $this->assertSame('IriTemplateMapping', $mapping['@type']); + $this->assertSame('name_converted', $mapping['property']); + } + } + + public function testOrderListSyntaxIsAccepted(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + self::createClient()->request('GET', '/converted_integers?order[]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesWithRelatedDummy(string $resource, string $relatedResource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy #'.$i); + + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($relatedDummy); + + $manager->persist($relatedDummy); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/RangeFilterTest.php b/tests/Functional/Doctrine/RangeFilterTest.php new file mode 100644 index 00000000000..d8da6fda3ee --- /dev/null +++ b/tests/Functional/Doctrine/RangeFilterTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class RangeFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class, ConvertedInteger::class]; + } + + protected function setUp(): void + { + parent::setUp(); + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 30); + } + + #[TestWith(['dummyPrice[between]=12.99..15.99', 15])] + #[TestWith(['dummyPrice[between]=12.99..12.99', 8])] + #[TestWith(['dummyPrice[between]=9.99..12.99..15.99', 30])] + #[TestWith(['dummyPrice[lt]=12.99', 8])] + #[TestWith(['dummyPrice[lte]=12.99', 16])] + #[TestWith(['dummyPrice[gt]=15.99', 7])] + #[TestWith(['dummyPrice[gte]=15.99', 14])] + #[TestWith(['dummyPrice[gt]=12.99&dummyPrice[lt]=19.99', 7])] + #[TestWith(['dummyPrice[gt]=19.99', 0])] + public function testRangeFilter(string $query, int $expectedTotal): void + { + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?name_converted[lte]=2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_integers/1', '/converted_integers/2'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithPrice(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $prices = ['9.99', '12.99', '15.99', '19.99']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyPrice($prices[($i - 1) % 4]); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/SearchFilterTest.php b/tests/Functional/Doctrine/SearchFilterTest.php new file mode 100644 index 00000000000..a5a42fc3231 --- /dev/null +++ b/tests/Functional/Doctrine/SearchFilterTest.php @@ -0,0 +1,802 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\MainResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\SubResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5648\DummyResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Group; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Component\Uid\Uuid as SymfonyUuid; + +final class SearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyFriend::class, + RelatedToDummyFriend::class, + EmbeddedDummy::class, + ThirdLevel::class, + FourthLevel::class, + DummyCar::class, + DummyCarColor::class, + DummyDate::class, + ConvertedOwner::class, + ConvertedRelated::class, + DummyResource::class, + MainResource::class, + SubResource::class, + DummyWithSubEntity::class, + DummySubEntity::class, + Group::class, + Issue5735User::class, + ]; + } + + public function testManyToManyWithFilterOnJoinTable(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $this->recreateSchema([ + RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(4); + + $response = self::createClient()->request('GET', '/related_dummies?relatedToDummyFriend.dummyFriend=/dummy_friends/4', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(1, $data['_embedded']['item']); + $this->assertSame(1, $data['_embedded']['item'][0]['id']); + $this->assertCount(4, $data['_embedded']['item'][0]['_links']['relatedToDummyFriend']); + $this->assertCount(4, $data['_embedded']['item'][0]['_embedded']['relatedToDummyFriend']); + } + + public function testSearchManyToManyWithRelatedEntity(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('DummyCar/Color is ORM only in this scenario.'); + } + $this->recreateSchema([DummyCar::class, DummyCarColor::class]); + $this->createDummyCarWithColors(); + + $response = self::createClient()->request('GET', '/dummy_cars?colors.prop=red', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummy_cars/1', $data['hydra:member'][0]['@id']); + $this->assertCount(2, $data['hydra:member'][0]['colors']); + $this->assertSame('red', $data['hydra:member'][0]['colors'][0]['prop']); + $this->assertSame('blue', $data['hydra:member'][0]['colors'][1]['prop']); + } + + public function testSearchByNamePartial(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name=my', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchEmbeddedByName(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + $this->createEmbeddedDummies($embeddedClass, $embeddableClass, 30); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyName=my', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByNameMultipleValues(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name[]=2&name[]=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/2', '/dummies/3', '/dummies/12'], $ids); + } + + public function testSearchByDummyCaseInsensitive(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?dummy=somedummytest1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + foreach ($response->toArray()['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('/^SomeDummyTest\d{1,2}$/', $member['dummy']); + } + } + + public function testSearchByAliasStart(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?alias=Ali', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchByDescriptionMultipleStart(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description[]=Sma&description[]=Not', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchByDescriptionWordStartSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific: case-insensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description=smart', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByDescriptionWordStartMultipleSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific: case-insensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description[]=smart&description[]=so', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByDescriptionWordStartPostgres(): void + { + if (!$this->isPostgres()) { + $this->markTestSkipped('Postgres-specific: case-sensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description=smart', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/2', '/dummies/4', '/dummies/6'], $ids); + } + + public function testSearchEmptyResult(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name=MuYm', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame([], $response->toArray()['hydra:member']); + } + + public function testSearchByExistingCollectionRouteNameSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies=dummy_cars', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertIsArray($response->toArray()['hydra:member']); + } + + public function testSearchRelatedCollectionByName(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 3, 3); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies.name=RelatedDummy1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(3, $data['_embedded']['item']); + foreach ($data['_embedded']['item'] as $item) { + $this->assertCount(3, $item['_links']['relatedDummies']); + } + } + + public function testSearchByRelatedCollectionId(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 2, 2); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies=3', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(1, $data['totalItems']); + $this->assertCount(1, $data['_links']['item']); + $this->assertSame('/dummies/2', $data['_links']['item'][0]['href']); + } + + public function testCollectionByIdNonInteger(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?id=9.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $response->toArray()['hydra:member']) + ); + } + + public function testCollectionById(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?id=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('/dummies/10', $data['hydra:member'][0]['@id']); + } + + public function testCollectionFilteredByUnknownProperty(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?unknown=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + + $response = self::createClient()->request('GET', '/dummies?unknown=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchAtThirdLevel(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource, $this->thirdLevelClass(), $this->fourthLevelClass()]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 30, 0); + $this->createDummyWithFourthLevelRelation(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.level=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(['/dummies/31'], array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testSearchAtFourthLevel(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource, $this->thirdLevelClass(), $this->fourthLevelClass()]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 30, 0); + $this->createDummyWithFourthLevelRelation(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.fourthLevel.level=4', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(['/dummies/31'], array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testSearchUsingNameConverter(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name_converted=Converted 3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/3', '/dummies/30'], $ids); + } + + public function testSearchUsingNestedNameConverter(): void + { + $ownerClass = $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class; + $relatedClass = $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class; + $this->recreateSchema([$ownerClass, $relatedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $related = new $relatedClass(); + $related->nameConverted = 'Converted '.$i; + $owner = new $ownerClass(); + $owner->nameConverted = $related; + $manager->persist($related); + $manager->persist($owner); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_owners?name_converted.name_converted=Converted 3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/converted_owners/3', '/converted_owners/30'], $ids); + } + + public function testSearchByDate(): void + { + $resource = $this->isMongoDB() ? DummyDateDocument::class : DummyDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $dummy = new $resource(); + $dummy->dummyDate = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_dates?dummyDate=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testCustomSearchFilterUsingDoctrineExpressions(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Custom Doctrine expression filter is ORM only.'); + } + + $this->recreateSchema([Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]); + $this->createDummyWithRelatedDummiesAndThirdLevel(3); + + $response = self::createClient()->request('GET', '/dummy_resource_with_custom_filter?custom=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testSearchOnSubEntityWithStringIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('DummySubEntity is ORM only.'); + } + + $this->recreateSchema([DummyWithSubEntity::class, DummySubEntity::class]); + $manager = $this->getManager(); + $subEntity = new DummySubEntity('stringId', 'someName'); + $mainEntity = new DummyWithSubEntity(); + $mainEntity->setSubEntity($subEntity); + $mainEntity->setName('main'); + $manager->persist($subEntity); + $manager->persist($mainEntity); + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_with_subresource?subEntity=/dummy_subresource/stringId', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testFiltersCanUseUuids(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Issue5735 fixture is ORM only.'); + } + + $this->recreateSchema([Group::class, Issue5735User::class]); + $manager = $this->getManager(); + + $group1 = new Group(); + $group1->setUuid(SymfonyUuid::fromString('61817181-0ecc-42fb-a6e7-d97f2ddcb344')); + $manager->persist($group1); + for ($i = 0; $i < 2; ++$i) { + $user = new Issue5735User(); + $user->addGroup($group1); + $manager->persist($user); + } + $manager->persist(new Issue5735User()); + + $group2 = new Group(); + $group2->setUuid(SymfonyUuid::fromString('32510d53-f737-4e70-8d9d-58e292c871f8')); + $manager->persist($group2); + $user = new Issue5735User(); + $user->addGroup($group2); + $manager->persist($user); + $manager->persist(new Issue5735User()); + + $manager->flush(); + + $response = self::createClient()->request( + 'GET', + '/issue5735/issue5735_users?groups[]=/issue5735/groups/61817181-0ecc-42fb-a6e7-d97f2ddcb344&groups[]=/issue5735/groups/32510d53-f737-4e70-8d9d-58e292c871f8', + ['headers' => ['Accept' => 'application/ld+json']] + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @return class-string + */ + private function thirdLevelClass(): string + { + return $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class; + } + + /** + * @return class-string + */ + private function fourthLevelClass(): string + { + return $this->isMongoDB() ? FourthLevelDocument::class : FourthLevel::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + */ + private function createEmbeddedDummies(string $embeddedClass, string $embeddableClass, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesEachWithRelatedDummies(string $resource, string $relatedResource, int $nb, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + for ($j = 1; $j <= $nbRelated; ++$j) { + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy'.$j.$i); + $relatedDummy->setAge((int) ($j.$i)); + $manager->persist($relatedDummy); + $dummy->addRelatedDummy($relatedDummy); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function createRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + $manager->persist($relation); + } + $manager->flush(); + $manager->clear(); + } + + private function createDummyCarWithColors(): void + { + $manager = $this->getManager(); + $car = new DummyCar(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + + $color1 = new DummyCarColor(); + $color1->setProp('red'); + $color1->setCar($car); + $manager->persist($color1); + + $color2 = new DummyCarColor(); + $color2->setProp('blue'); + $color2->setCar($car); + $manager->persist($color2); + $manager->flush(); + + $car->setColors(new ArrayCollection([$color1, $color2])); + $manager->persist($car); + $manager->flush(); + } + + private function createDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + + $fourthLevelClass = $this->fourthLevelClass(); + $thirdLevelClass = $this->thirdLevelClass(); + $relatedDummyClass = $this->relatedDummyClass(); + $dummyClass = $this->dummyClass(); + + $fourthLevel = new $fourthLevelClass(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new $thirdLevelClass(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new $relatedDummyClass(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new $relatedDummyClass(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new $dummyClass(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + $manager->clear(); + } + + private function createDummyWithRelatedDummiesAndThirdLevel(int $nb): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setThirdLevel($thirdLevel); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + } + $manager->persist($dummy); + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/SeparatedResourceTest.php b/tests/Functional/Doctrine/SeparatedResourceTest.php new file mode 100644 index 00000000000..b19cd54d433 --- /dev/null +++ b/tests/Functional/Doctrine/SeparatedResourceTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EntityClassAndCustomProviderResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ResourceWithSeparatedEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResourceOdm\ResourceWithSeparatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SeparatedEntity as SeparatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SeparatedEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SeparatedResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + ResourceWithSeparatedEntity::class, + ResourceWithSeparatedDocument::class, + EntityClassAndCustomProviderResource::class, + ]; + } + + public function testGetCollection(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents' : '/separated_entities'; + $shortName = $this->isMongoDB() ? 'SeparatedDocument' : 'SeparatedEntity'; + + $response = self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/'.$shortName, $data['@context']); + $this->assertStringStartsWith($uri, $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertIsArray($data['hydra:member']); + $this->assertIsInt($data['hydra:totalItems']); + $this->assertArrayHasKey('hydra:view', $data); + } + + public function testGetOrderedCollection(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents' : '/separated_entities'; + + $response = self::createClient()->request('GET', $uri.'?order[value]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertSame('5', $response->toArray()['hydra:member'][0]['value']); + } + + public function testGetItem(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents/1' : '/separated_entities/1'; + + self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testGetAllEntityClassAndCustomProviderResources(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('EntityClassAndCustomProviderResource uses ORM stateOptions only.'); + } + + $this->recreateSchema([SeparatedEntity::class]); + $this->createSeparatedEntities(SeparatedEntity::class, 1); + + self::createClient()->request('GET', '/entityClassAndCustomProviderResources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + public function testGetOneEntityClassAndCustomProviderResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('EntityClassAndCustomProviderResource uses ORM stateOptions only.'); + } + + $this->recreateSchema([SeparatedEntity::class]); + $this->createSeparatedEntities(SeparatedEntity::class, 1); + + self::createClient()->request('GET', '/entityClassAndCustomProviderResources/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + /** + * @param class-string $resource + */ + private function createSeparatedEntities(string $resource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $entity = new $resource(); + $entity->value = (string) $i; + $manager->persist($entity); + } + $manager->flush(); + } +} diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 8d340915433..3fa939623c8 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Composer\InstalledVersions; +use Composer\Semver\VersionParser; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * @see https://github.com/api-platform/core/issues/8183 @@ -65,8 +67,13 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void $this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.'); } + #[IgnoreDeprecations] public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { + if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { + $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); + } + $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => 'unknown'], diff --git a/tests/Functional/GraphQl/AuthorizationTest.php b/tests/Functional/GraphQl/AuthorizationTest.php new file mode 100644 index 00000000000..14a238c1ba9 --- /dev/null +++ b/tests/Functional/GraphQl/AuthorizationTest.php @@ -0,0 +1,590 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedLinkedDummy as RelatedLinkedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedSecuredDummy as RelatedSecuredDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as SecuredDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedSecuredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AuthorizationTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private const ADMIN_AUTH = 'Basic YWRtaW46a2l0dGVu'; + private const DUNGLAS_AUTH = 'Basic ZHVuZ2xhczprZXZpbg=='; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + SecuredDummy::class, + RelatedDummy::class, + RelatedSecuredDummy::class, + RelatedLinkedDummy::class, + ]; + } + + public function testAnonymousCannotReadSecuredItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + title + description + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummy']); + } + + public function testAnonymousCannotReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummies']); + } + + public function testAdminCanReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNotNull($response->toArray()['data']['securedDummies']); + } + + public function testUserCannotReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertNull($data['data']['securedDummies']); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + } + + public function testAnonymousCannotCreateSecuredResource(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { + securedDummy { + title + owner + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Only admins can create a secured dummy.', $data['errors'][0]['message']); + $this->assertNull($data['data']['createSecuredDummy']); + } + + public function testAdminCanAccessSecuredRelationsOwnedByAdmin(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'admin'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedDummies { edges { node { id } } } + relatedDummy { id } + relatedSecuredDummies { edges { node { id } } } + relatedSecuredDummy { id } + publicRelatedSecuredDummies { edges { node { id } } } + publicRelatedSecuredDummy { id } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['securedDummy']; + $this->assertCount(1, $data['relatedDummies']['edges']); + $this->assertNotNull($data['relatedDummy']); + $this->assertCount(1, $data['relatedSecuredDummies']['edges']); + $this->assertNotNull($data['relatedSecuredDummy']); + $this->assertCount(1, $data['publicRelatedSecuredDummies']['edges']); + $this->assertNotNull($data['publicRelatedSecuredDummy']); + } + + public function testUserCannotReadSecuredCollectionRelationOnSecuredItemTheyDoNotOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'someone-else'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedDummies { edges { node { id } } } + relatedDummy { id } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $secured = $response->toArray(false)['data']['securedDummy']; + $this->assertNull($secured['relatedDummies']); + $this->assertNull($secured['relatedDummy']); + } + + public function testUserCannotAccessRelatedSecuredDummyDirectly(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + relatedSecuredDummy(id: "/related_secured_dummies/1") { + id + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['relatedSecuredDummy']); + } + + public function testUserCannotListRelatedSecuredDummies(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + relatedSecuredDummies { + edges { node { id } } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['relatedSecuredDummies']); + } + + public function testUserCanAccessSecuredRelationsOnOwnedDummy(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedSecuredDummies { edges { node { id } } } + relatedSecuredDummy { id } + publicRelatedSecuredDummies { edges { node { id } } } + publicRelatedSecuredDummy { id } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['securedDummy']; + $this->assertCount(1, $data['relatedSecuredDummies']['edges']); + $this->assertNotNull($data['relatedSecuredDummy']); + $this->assertCount(1, $data['publicRelatedSecuredDummies']['edges']); + $this->assertNotNull($data['publicRelatedSecuredDummy']); + } + + public function testAdminCanCreateSecuredResource(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { + securedDummy { + id + title + owner + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('someone', $response->toArray()['data']['createSecuredDummy']['securedDummy']['owner']); + } + + public function testAdminCanCreateOwnerOnlyPropertyWhenAdminIsOwner(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "admin", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "it works"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('it works', $response->toArray()['data']['createSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testAdminCannotSetOwnerOnlyPropertyWhenNotOwner(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "should not be set"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['createSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCannotReadItemTheyDoNotOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('admin'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummy']); + } + + public function testUserCanReadItemTheyOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('dunglas', $response->toArray()['data']['securedDummy']['owner']); + } + + public function testAdminCanReadAdminOnlyPropertyOnOtherUsersItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', adminProperty: 'admin secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + adminOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('admin secret', $response->toArray()['data']['securedDummy']['adminOnlyProperty']); + } + + public function testUserCannotReadAdminOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', adminProperty: 'admin secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + adminOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['securedDummy']['adminOnlyProperty']); + } + + public function testUserCanReadOwnerOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'owner secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + ownerOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('owner secret', $response->toArray()['data']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCanUpdateOwnerOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'original'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", ownerOnlyProperty: "updated"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('updated', $response->toArray()['data']['updateSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testAdminCannotReadOwnerOnlyPropertyOnOtherUsersItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'owner secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + ownerOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCannotAssignItemTheyDoNotOwnToThemselves(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('someone'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "kitten"}) { + securedDummy { id title owner } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['updateSecuredDummy']); + } + + public function testUserCanTransferOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "vincent"}) { + securedDummy { id title owner } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('vincent', $response->toArray()['data']['updateSecuredDummy']['securedDummy']['owner']); + } + + private function recreateAuthSchema(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? SecuredDummyDocument::class : SecuredDummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? RelatedSecuredDummyDocument::class : RelatedSecuredDummy::class, + $this->isMongoDB() ? RelatedLinkedDummyDocument::class : RelatedLinkedDummy::class, + ]); + } + + private function newSecuredDummy(): object + { + $class = $this->isMongoDB() ? SecuredDummyDocument::class : SecuredDummy::class; + + return new $class(); + } + + private function newRelatedDummy(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function newRelatedSecuredDummy(): object + { + $class = $this->isMongoDB() ? RelatedSecuredDummyDocument::class : RelatedSecuredDummy::class; + + return new $class(); + } + + private function newRelatedLinkedDummy(): object + { + $class = $this->isMongoDB() ? RelatedLinkedDummyDocument::class : RelatedLinkedDummy::class; + + return new $class(); + } + + private function seedSecuredDummies(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $d = $this->newSecuredDummy(); + $d->setTitle("#$i"); + $d->setDescription("Hello #$i"); + $d->setOwner('notexist'); + $manager->persist($d); + } + $manager->flush(); + } + + private function seedSecuredDummyWithOwner(string $owner, ?string $adminProperty = null, ?string $ownerProperty = null): void + { + $manager = $this->getManager(); + $d = $this->newSecuredDummy(); + $d->setTitle('#1'); + $d->setDescription('Hello #1'); + $d->setOwner($owner); + if (null !== $adminProperty) { + $d->setAdminOnlyProperty($adminProperty); + } + if (null !== $ownerProperty) { + $d->setOwnerOnlyProperty($ownerProperty); + } + $manager->persist($d); + $manager->flush(); + } + + private function seedSecuredDummiesWithRelations(int $count, string $owner): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $secured = $this->newSecuredDummy(); + $secured->setTitle("#$i"); + $secured->setDescription("Hello #$i"); + $secured->setOwner($owner); + + $related = $this->newRelatedDummy(); + $related->setName('RelatedDummy'); + $manager->persist($related); + + $relatedSecured = $this->newRelatedSecuredDummy(); + $manager->persist($relatedSecured); + + $publicRelated = $this->newRelatedSecuredDummy(); + $manager->persist($publicRelated); + + $linked = $this->newRelatedLinkedDummy(); + $manager->persist($linked); + + $secured->addRelatedDummy($related); + $secured->setRelatedDummy($related); + $secured->addRelatedSecuredDummy($relatedSecured); + $secured->setRelatedSecuredDummy($relatedSecured); + $secured->addPublicRelatedSecuredDummy($publicRelated); + $secured->setPublicRelatedSecuredDummy($publicRelated); + $linked->setSecuredDummy($secured); + + $manager->persist($secured); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/CollectionTest.php b/tests/Functional/GraphQl/CollectionTest.php new file mode 100644 index 00000000000..e7931490ce1 --- /dev/null +++ b/tests/Functional/GraphQl/CollectionTest.php @@ -0,0 +1,923 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\VideoGame as VideoGameDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ThirdLevel::class, + DummyGroup::class, + DummyCustomQuery::class, + DummyDifferentGraphQlSerializationGroup::class, + Foo::class, + FooDummy::class, + SoMany::class, + MusicGroup::class, + VideoGame::class, + CompositeRelation::class, + CompositeItem::class, + CompositeLabel::class, + CompositePrimitiveItem::class, + ]; + } + + public function testRetrieveCollectionWithRelations(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummyAndThirdLevel(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + ...dummyFields + } + } + fragment dummyFields on DummyCursorConnection { + edges { + node { + id + name + relatedDummy { + name + thirdLevel { id level } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #3', $edges[2]['node']['name']); + $this->assertSame('RelatedDummy #3', $edges[2]['node']['relatedDummy']['name']); + $this->assertSame(3, $edges[2]['node']['relatedDummy']['thirdLevel']['level']); + } + + public function testRetrieveEmptyCollection(): void + { + $this->recreateDummiesAndRelated(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name } } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(0, $data['edges']); + $this->assertNull($data['pageInfo']['endCursor']); + $this->assertNull($data['pageInfo']['startCursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + } + + public function testRetrieveCollectionWithNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(4, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { + node { + name + relatedDummies { + edges { node { name } } + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #3', $edges[2]['node']['name']); + $this->assertSame('RelatedDummy23', $edges[2]['node']['relatedDummies']['edges'][1]['node']['name']); + } + + public function testRetrieveInverseSideNestedCollection(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? VideoGameDocument::class : VideoGame::class, + $this->isMongoDB() ? MusicGroupDocument::class : MusicGroup::class, + ]); + $this->seedVideoGameWithMusicGroups(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + musicGroups { + edges { + node { + name + videoGames { edges { node { name } } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['musicGroups']['edges']; + $this->assertSame('Sum 41', $edges[0]['node']['name']); + $this->assertSame('Guitar Hero', $edges[0]['node']['videoGames']['edges'][0]['node']['name']); + $this->assertSame('Franz Ferdinand', $edges[1]['node']['name']); + $this->assertSame('Guitar Hero', $edges[1]['node']['videoGames']['edges'][0]['node']['name']); + } + + public function testRetrieveCollectionAndItemTogether(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class, + $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class, + ]); + $this->seedDummiesWithDate(3); + $this->seedDummyGroups(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name dummyDate } } + } + dummyGroup(id: "/dummy_groups/2") { + foo + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertSame('Dummy #2', $data['dummies']['edges'][1]['node']['name']); + $this->assertSame('2015-04-02', $data['dummies']['edges'][1]['node']['dummyDate']); + $this->assertSame('Foo #2', $data['dummyGroup']['foo']); + } + + public function testFirstNItems(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2) { + edges { node { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertCount(2, $response->toArray()['data']['dummies']['edges']); + } + + public function testFirstNItemsOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(2, 5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 1) { + edges { + node { + name + relatedDummies(first: 2) { + edges { node { name } } + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertCount(2, $edges[0]['node']['relatedDummies']['edges']); + } + + public function testPaginationCursorsForward(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2) { + edges { cursor node { name } } + totalCount + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame(4, $data['totalCount']); + $this->assertSame('MQ==', $data['pageInfo']['endCursor']); + $this->assertTrue($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + $this->assertSame('MQ==', $data['edges'][1]['cursor']); + $this->assertSame('Dummy #2', $data['edges'][1]['node']['name']); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2, after: "MQ==") { + edges { cursor node { name } } + pageInfo { endCursor hasNextPage } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame('Dummy #3', $data['edges'][0]['node']['name']); + $this->assertSame('Mg==', $data['edges'][0]['cursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + } + + public function testPaginationCursorsBackward(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(last: 2) { + edges { cursor node { name } } + totalCount + pageInfo { startCursor hasPreviousPage hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame(4, $data['totalCount']); + $this->assertSame('Mg==', $data['pageInfo']['startCursor']); + $this->assertTrue($data['pageInfo']['hasPreviousPage']); + $this->assertSame('Dummy #4', $data['edges'][1]['node']['name']); + $this->assertSame('Mw==', $data['edges'][1]['cursor']); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(last: 2, before: "Mw==") { + edges { cursor node { name } } + pageInfo { startCursor hasPreviousPage } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame('Dummy #2', $data['edges'][0]['node']['name']); + $this->assertSame('MQ==', $data['edges'][0]['cursor']); + } + + public function testSoManyPartialPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('SoMany scenario @!mongodb'); + } + $this->recreateSchema([SoMany::class]); + $this->seedSoManies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + soManies(first: 2) { + edges { cursor node { content } } + totalCount + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['soManies']; + $this->assertSame('MA==', $data['pageInfo']['startCursor']); + $this->assertSame('MQ==', $data['pageInfo']['endCursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + $this->assertSame(0, $data['totalCount']); + $this->assertSame('Many #2', $data['edges'][1]['node']['content']); + $this->assertSame('MQ==', $data['edges'][1]['cursor']); + } + + public function testCollectionWithPaginationDisabled(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + $this->seedFoosWithFakeNames(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + foos { + id + name + bar + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $foos = $response->toArray()['data']['foos']; + $this->assertSame('/foos/4', $foos[3]['id']); + $this->assertSame('Separativeness', $foos[3]['name']); + $this->assertSame('Sit', $foos[3]['bar']); + } + + public function testCustomCollectionQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionDummyCustomQueries { + edges { node { message } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testCollectionDummyCustomQueries' => [ + 'edges' => [ + ['node' => ['message' => 'Success!']], + ['node' => ['message' => 'Success!']], + ], + ], + ], + ], $response->toArray()); + } + + public function testCustomCollectionQueryReadAndSerializeFalse(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionNoReadAndSerializeDummyCustomQueries { + edges { node { message } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['testCollectionNoReadAndSerializeDummyCustomQueries' => ['edges' => []]], + ], $response->toArray()); + } + + public function testCustomCollectionQueryWithCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionCustomArgumentsDummyCustomQueries(customArgumentString: "A string") { + edges { node { message customArgs } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testCollectionCustomArgumentsDummyCustomQueries' => [ + 'edges' => [ + ['node' => ['message' => 'Success!', 'customArgs' => ['customArgumentString' => 'A string']]], + ['node' => ['message' => 'Success!', 'customArgs' => ['customArgumentString' => 'A string']]], + ], + ], + ], + ], $response->toArray()); + } + + public function testRetrieveCompositePrimitiveIdentifierItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositePrimitiveItem::class]); + $manager = $this->getManager(); + $foo = new CompositePrimitiveItem('Foo', 2016); + $foo->setDescription('This is foo.'); + $manager->persist($foo); + $bar = new CompositePrimitiveItem('Bar', 2017); + $bar->setDescription('This is bar.'); + $manager->persist($bar); + $manager->flush(); + $manager->clear(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + compositePrimitiveItem(id: "/composite_primitive_items/name=Bar;year=2017") { + description + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('This is bar.', $response->toArray()['data']['compositePrimitiveItem']['description']); + } + + public function testRetrieveCompositeIdentifierItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + compositeRelation(id: "/composite_relations/compositeItem=1;compositeLabel=1") { + value + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('somefoobardummy', $response->toArray()['data']['compositeRelation']['value']); + } + + public function testCollectionWithNameConverter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name_converted } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'Converted 2', + $response->toArray()['data']['dummies']['edges'][1]['node']['name_converted'], + ); + } + + public function testCollectionWithDifferentSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class]); + $this->seedDummyDifferentGroups(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDifferentGraphQlSerializationGroups { + edges { node { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummyDifferentGraphQlSerializationGroups']['edges']; + $this->assertCount(3, $edges); + $this->assertArrayHasKey('name', $edges[0]['node']); + $this->assertArrayNotHasKey('title', $edges[0]['node']); + } + + public function testPageBasedPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1) { + collection { id name } + paginationInfo { itemsPerPage lastPage totalCount hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(3, $data['collection']); + $this->assertSame(3, $data['paginationInfo']['itemsPerPage']); + $this->assertSame(2, $data['paginationInfo']['lastPage']); + $this->assertSame(5, $data['paginationInfo']['totalCount']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 3) { collection { id name } } } + QUERY); + $this->assertCount(0, $response->toArray()['data']['fooDummies']['collection']); + } + + public function testPageBasedPaginationWithItemsPerPage(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 1, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 3, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(1, $response->toArray()['data']['fooDummies']['collection']); + } + + public function testMixedPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1) { + collection { + id name + soManies(first: 2) { + edges { cursor node { content } } + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + paginationInfo { itemsPerPage lastPage totalCount hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(3, $data['collection']); + $this->assertCount(2, $data['collection'][2]['soManies']['edges']); + $this->assertSame('So many 1', $data['collection'][2]['soManies']['edges'][1]['node']['content']); + $this->assertSame('MA==', $data['collection'][2]['soManies']['pageInfo']['startCursor']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + } + + public function testPaginationOnlyHasNextPage(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1, itemsPerPage: 2) { + collection { + id name + soManies(first: 2) { + edges { node { content } cursor } + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + paginationInfo { hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(2, $data['collection']); + $this->assertArrayHasKey('id', $data['collection'][1]); + $this->assertArrayHasKey('name', $data['collection'][1]); + $this->assertCount(2, $data['collection'][1]['soManies']['edges']); + $this->assertSame('So many 1', $data['collection'][1]['soManies']['edges'][1]['node']['content']); + $this->assertSame('MA==', $data['collection'][1]['soManies']['pageInfo']['startCursor']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2) { paginationInfo { hasNextPage } } } + QUERY); + $this->assertFalse($response->toArray()['data']['fooDummies']['paginationInfo']['hasNextPage']); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function newThirdLevel(): object + { + $class = $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class; + + return new $class(); + } + + private function seedDummies(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithDate(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + if ($count !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesEachWithRelatedDummies(int $count, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + + for ($j = 1; $j <= $nbRelated; ++$j) { + $related = $this->newRelated(); + $related->setName('RelatedDummy'.$j.$i); + $related->setAge((int) ($j.$i)); + $manager->persist($related); + $dummy->addRelatedDummy($related); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithRelatedDummyAndThirdLevel(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $third = $this->newThirdLevel(); + + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + $related->setThirdLevel($third); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setRelatedDummy($related); + + $manager->persist($third); + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummyGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $g = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $p) { + $g->{$p} = ucfirst($p).' #'.$i; + } + $manager->persist($g); + } + $manager->flush(); + } + + private function seedDummyCustomQuery(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class; + for ($i = 1; $i <= $count; ++$i) { + $manager->persist(new $class()); + } + $manager->flush(); + } + + private function seedDummyDifferentGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $d = new $class(); + $d->setName('Name #'.$i); + $d->setTitle('Title #'.$i); + $manager->persist($d); + } + $manager->flush(); + } + + private function seedFoosWithFakeNames(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? FooDocument::class : Foo::class; + $names = ['Hawsepipe', 'Sthenelus', 'Ephesian', 'Separativeness', 'Balbo']; + $bars = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + for ($i = 0; $i < $count; ++$i) { + $foo = new $class(); + $foo->setName($names[$i]); + $foo->setBar($bars[$i]); + $manager->persist($foo); + } + $manager->flush(); + } + + private function seedFooDummies(int $count): void + { + $manager = $this->getManager(); + $fooClass = $this->isMongoDB() ? FooDummyDocument::class : FooDummy::class; + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $soManyClass = $this->isMongoDB() ? SoManyDocument::class : SoMany::class; + $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; + $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + + for ($i = 0; $i < $count; ++$i) { + $dummy = new $dummyClass(); + $dummy->setName($dummies[$i]); + + $foo = new $fooClass(); + $foo->setName($names[$i]); + $foo->setDummy($dummy); + for ($j = 0; $j < 3; ++$j) { + $soMany = new $soManyClass(); + $soMany->content = "So many $j"; + $soMany->fooDummy = $foo; + $foo->soManies->add($soMany); + } + $manager->persist($foo); + } + $manager->flush(); + } + + private function seedSoManies(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? SoManyDocument::class : SoMany::class; + for ($i = 1; $i <= $count; ++$i) { + $s = new $class(); + $s->content = 'Many #'.$i; + $manager->persist($s); + } + $manager->flush(); + } + + private function seedVideoGameWithMusicGroups(): void + { + $manager = $this->getManager(); + $musicClass = $this->isMongoDB() ? MusicGroupDocument::class : MusicGroup::class; + $videoClass = $this->isMongoDB() ? VideoGameDocument::class : VideoGame::class; + + $sum41 = new $musicClass(); + $sum41->name = 'Sum 41'; + $manager->persist($sum41); + + $franz = new $musicClass(); + $franz->name = 'Franz Ferdinand'; + $manager->persist($franz); + + $videoGame = new $videoClass(); + $videoGame->name = 'Guitar Hero'; + $videoGame->addMusicGroup($sum41); + $videoGame->addMusicGroup($franz); + $manager->persist($videoGame); + $manager->flush(); + } + + private function seedCompositeIdentifierObjects(): void + { + $manager = $this->getManager(); + $item = new CompositeItem(); + $item->setField1('foobar'); + $manager->persist($item); + $manager->flush(); + + for ($i = 0; $i < 4; ++$i) { + $label = new CompositeLabel(); + $label->setValue('foo-'.$i); + $manager->persist($label); + $manager->flush(); + + $rel = new CompositeRelation(); + $rel->setCompositeLabel($label); + $rel->setCompositeItem($item); + $rel->setValue('somefoobardummy'); + $manager->persist($rel); + } + $manager->flush(); + $manager->clear(); + } +} diff --git a/tests/Functional/GraphQl/CustomTypeTest.php b/tests/Functional/GraphQl/CustomTypeTest.php new file mode 100644 index 00000000000..6fb33832662 --- /dev/null +++ b/tests/Functional/GraphQl/CustomTypeTest.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomTypeTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $resource = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $this->recreateSchema([$resource]); + $this->seedDummies($resource); + } + + public function testQueryFieldWithCustomType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + dummyDate + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2015-04-01', $response->toArray()['data']['dummy']['dummyDate']); + } + + public function testMutationInputWithCustomType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateDummy(input: {id: "/dummies/1", dummyDate: "2019-05-24T00:00:00+00:00"}) { + dummy { + dummyDate + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2019-05-24', $response->toArray()['data']['updateDummy']['dummy']['dummyDate']); + } + + public function testMutationVariableWithCustomType(): void + { + $response = $this->executeGraphQl( + <<<'QUERY' + mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { + updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { + dummy { + dummyDate + } + } + } + QUERY, + ['itemId' => '/dummies/1', 'itemDate' => '2017-11-14T00:00:00+00:00'], + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2017-11-14', $response->toArray()['data']['updateDummy']['dummy']['dummyDate']); + } + + public function testMutationVariableWithCustomTypeAndBadValue(): void + { + $response = $this->executeGraphQl( + <<<'QUERY' + mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { + updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { + dummy { + dummyDate + } + } + } + QUERY, + ['itemId' => '/dummies/1', 'itemDate' => 'bad date'], + ); + + $this->assertResponseIsSuccessful(); + $message = $response->toArray(false)['errors'][0]['message'] ?? ''; + $this->assertStringContainsString('Variable "$itemDate" got invalid value "bad date";', $message); + $this->assertStringContainsString('DateTime cannot represent non date value: "bad date"', $message); + } + + private function seedDummies(string $resourceClass): void + { + $manager = $this->getManager(); + $dummy1 = new $resourceClass(); + $dummy1->setName('Dummy #1'); + $dummy1->setAlias('Alias #1'); + $dummy1->setDescription('Smart dummy.'); + $dummy1->setDummyDate(new \DateTime('2015-04-01', new \DateTimeZone('UTC'))); + $manager->persist($dummy1); + + $dummy2 = new $resourceClass(); + $dummy2->setName('Dummy #2'); + $dummy2->setAlias('Alias #0'); + $dummy2->setDescription('Not so smart dummy.'); + $manager->persist($dummy2); + + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/DocsTest.php b/tests/Functional/GraphQl/DocsTest.php new file mode 100644 index 00000000000..f2bc0c20407 --- /dev/null +++ b/tests/Functional/GraphQl/DocsTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + +final class DocsTest extends ApiTestCase +{ + protected static ?bool $alwaysBootKernel = false; + + public function testRetrieveGraphiQlDocumentation(): void + { + self::createClient()->request('GET', '/graphql', ['headers' => ['Accept' => 'text/html']]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'text/html; charset=UTF-8'); + } +} diff --git a/tests/Functional/GraphQl/FilterTest.php b/tests/Functional/GraphQl/FilterTest.php new file mode 100644 index 00000000000..a7d40722d66 --- /dev/null +++ b/tests/Functional/GraphQl/FilterTest.php @@ -0,0 +1,528 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; + +final class FilterTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ConvertedOwner::class, + ConvertedRelated::class, + DummyCar::class, + DummyCarColor::class, + ]; + } + + public function testBooleanFilter(): void + { + $this->recreateDummiesAndRelated(); + $manager = $this->getManager(); + $true = $this->newDummy(); + $true->setName('Dummy #1'); + $true->setDummyBoolean(true); + $manager->persist($true); + + $false = $this->newDummy(); + $false->setName('Dummy #2'); + $false->setDummyBoolean(false); + $manager->persist($false); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(dummyBoolean: false) { + edges { node { id dummyBoolean } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertFalse($edges[0]['node']['dummyBoolean']); + } + + public function testExistsFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(3); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(exists: [{relatedDummy: true}]) { + edges { + node { + id + relatedDummy { name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(2, $edges); + $this->assertArrayHasKey('name', $edges[0]['node']['relatedDummy']); + } + + public function testDateFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithDate(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(dummyDate: [{after: "2015-04-02"}]) { + edges { node { id dummyDate } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('2015-04-02', $edges[0]['node']['dummyDate']); + } + + public function testSearchFilterOnName(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(10); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name: "#2") { + edges { node { id name } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('/dummies/2', $edges[0]['node']['id']); + } + + public function testSearchFilterWithIntOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(4, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name: "Dummy #1") { + totalCount + edges { + node { + name + relatedDummies(age: 31) { + totalCount + edges { + node { id name age } + } + } + } + } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertSame(1, $data['totalCount']); + $this->assertSame(1, $data['edges'][0]['node']['relatedDummies']['totalCount']); + $this->assertSame('31', (string) $data['edges'][0]['node']['relatedDummies']['edges'][0]['node']['age']); + } + + public function testSearchFilterWithNameConverter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(10); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name_converted: "Converted 2") { + edges { node { id name name_converted } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('/dummies/2', $edges[0]['node']['id']); + $this->assertSame('Converted 2', $edges[0]['node']['name_converted']); + } + + public function testSearchFilterWithNameConverterOnNestedProperty(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class, + $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class, + ]); + $this->seedConvertedOwners(20); + + $response = $this->executeGraphQl(<<<'QUERY' + { + convertedOwners(name_converted__name_converted: "Converted 2") { + edges { + node { + id + name_converted { name_converted } + } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['convertedOwners']['edges']; + $this->assertCount(2, $edges); + $this->assertSame('/converted_owners/2', $edges[0]['node']['id']); + $this->assertSame('Converted 2', $edges[0]['node']['name_converted']['name_converted']); + $this->assertSame('/converted_owners/20', $edges[1]['node']['id']); + $this->assertSame('Converted 20', $edges[1]['node']['name_converted']['name_converted']); + } + + public function testSearchFilterOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(3, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { + node { + id + relatedDummies(name: "RelatedDummy13") { + edges { node { id name } } + } + } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(0, $edges[0]['node']['relatedDummies']['edges']); + $this->assertCount(0, $edges[1]['node']['relatedDummies']['edges']); + $this->assertCount(1, $edges[2]['node']['relatedDummies']['edges']); + $this->assertSame('RelatedDummy13', $edges[2]['node']['relatedDummies']['edges'][0]['node']['name']); + } + + public function testNestedCollectionFilter(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class, + $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class, + ]); + $this->seedDummyCarWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyCar(id: "/dummy_cars/1") { + id + colors(prop: "blue") { + edges { node { id prop } } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummyCar']['colors']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('blue', $edges[0]['node']['prop']); + } + + public function testRelatedSearchFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(1, 2); + $this->seedDummiesEachWithRelatedDummies(1, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(relatedDummies__name: "RelatedDummy31") { + edges { node { id } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response->toArray()['data']['dummies']['edges']); + } + + public function testOrderByNestedProperty(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(order: [{relatedDummy__name: "DESC"}]) { + edges { + node { + name + relatedDummy { id name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #2', $edges[0]['node']['name']); + $this->assertSame('Dummy #1', $edges[1]['node']['name']); + } + + public function testMultiKeyOrderRespectsArgumentOrder(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithSimilarProperties(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(order: [{description: "ASC"}, {name: "ASC"}]) { + edges { + node { id name description } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('baz', $edges[0]['node']['name']); + $this->assertSame('bar', $edges[0]['node']['description']); + $this->assertSame('foo', $edges[1]['node']['name']); + $this->assertSame('bar', $edges[1]['node']['description']); + } + + public function testRelatedSearchFilterMultiValueExact(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(relatedDummy__name_list: ["RelatedDummy #1", "RelatedDummy #2"]) { + edges { + node { + id + name + relatedDummy { name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(2, $edges); + $this->assertSame('RelatedDummy #1', $edges[0]['node']['relatedDummy']['name']); + $this->assertSame('RelatedDummy #2', $edges[1]['node']['relatedDummy']['name']); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummies(int $count): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithDate(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + if ($count !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesEachWithRelatedDummies(int $count, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + + for ($j = 1; $j <= $nbRelated; ++$j) { + $related = $this->newRelated(); + $related->setName('RelatedDummy'.$j.$i); + $related->setAge((int) ($j.$i)); + $manager->persist($related); + + $dummy->addRelatedDummy($related); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithSimilarProperties(): void + { + $manager = $this->getManager(); + foreach ([ + ['foo', 'bar'], + ['baz', 'qux'], + ['foo', 'qux'], + ['baz', 'bar'], + ] as [$name, $description]) { + $dummy = $this->newDummy(); + $dummy->setName($name); + $dummy->setDescription($description); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedConvertedOwners(int $count): void + { + $relatedClass = $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class; + $ownerClass = $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->nameConverted = 'Converted '.$i; + + $owner = new $ownerClass(); + $owner->nameConverted = $related; + + $manager->persist($related); + $manager->persist($owner); + } + $manager->flush(); + } + + private function seedDummyCarWithColors(): void + { + $manager = $this->getManager(); + $carClass = $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class; + $colorClass = $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class; + + $car = new $carClass(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + + if (\is_object($car->getId())) { + $manager->persist($car->getId()); + $manager->flush(); + } + + $red = new $colorClass(); + $red->setProp('red'); + $red->setCar($car); + $manager->persist($red); + $manager->flush(); + + $blue = new $colorClass(); + $blue->setProp('blue'); + $blue->setCar($car); + $manager->persist($blue); + $manager->flush(); + + $car->setColors(new ArrayCollection([$red, $blue])); + $manager->persist($car); + $manager->flush(); + } +} diff --git a/features/files/test.gif b/tests/Functional/GraphQl/Fixtures/test.gif similarity index 100% rename from features/files/test.gif rename to tests/Functional/GraphQl/Fixtures/test.gif diff --git a/tests/Functional/GraphQl/InputOutputTest.php b/tests/Functional/GraphQl/InputOutputTest.php new file mode 100644 index 00000000000..a120f54edda --- /dev/null +++ b/tests/Functional/GraphQl/InputOutputTest.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoInputOutput as DummyDtoInputOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MessengerWithInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + DummyDtoInputOutput::class, + DummyDtoNoOutput::class, + DummyDtoNoInput::class, + MessengerWithInput::class, + RelatedDummy::class, + ]; + } + + public function testRetrieveOutputAfterRestCreation(): void + { + $this->recreateSchema($this->resolveResources([ + DummyDtoInputOutput::class => DummyDtoInputOutputDocument::class, + RelatedDummy::class => RelatedDummyDocument::class, + ])); + $this->seedRelatedDummy(); + + $client = self::createClient(); + $client->request('POST', '/dummy_dto_input_outputs', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'test', 'bar' => 1, 'relatedDummies' => ['/related_dummies/1']], + ]); + $this->assertResponseStatusCodeSame(201); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { + _id, id, baz, + relatedDummies { + edges { + node { + name + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'dummyDtoInputOutput' => [ + '_id' => 1, + 'id' => '/dummy_dto_input_outputs/1', + 'baz' => 1, + 'relatedDummies' => [ + 'edges' => [ + ['node' => ['name' => 'RelatedDummy with friends']], + ], + ], + ], + ], + ], $response->toArray()); + } + + public function testCreateItemWithCustomInputAndOutput(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoInputOutput::class => DummyDtoInputOutputDocument::class])); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoInputOutput(input: {foo: "A foo", bar: 4, clientMutationId: "myId"}) { + dummyDtoInputOutput { + baz, + bat + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'createDummyDtoInputOutput' => [ + 'dummyDtoInputOutput' => ['baz' => 4, 'bat' => 'A foo'], + 'clientMutationId' => 'myId', + ], + ], + ], $response->toArray()); + } + + public function testCreateItemWithDisabledOutputClassFailsToQueryFields(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoNoOutput::class => DummyDtoNoOutputDocument::class])); + $this->seedDummyDtoNoOutput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { + dummyDtoNoOutput { + id + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame('Cannot query field "id" on type "DummyDtoNoOutput".', $data['errors'][0]['message']); + $this->assertSame(4, $data['errors'][0]['locations'][0]['line']); + $this->assertSame(7, $data['errors'][0]['locations'][0]['column']); + } + + public function testCreateItemWithDisabledInputClassRejectsUndefinedFields(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoNoInput::class => DummyDtoNoInputDocument::class])); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertMatchesRegularExpression( + '/^Field "lorem" is not defined by type "?createDummyDtoNoInputInput"?\.$/', + $data['errors'][0]['message'], + ); + $this->assertMatchesRegularExpression( + '/^Field "ipsum" is not defined by type "?createDummyDtoNoInputInput"?\.$/', + $data['errors'][1]['message'], + ); + } + + public function testMessengerWithInputReturnsSynchronousResult(): void + { + // MessengerWithInput is not a Doctrine resource — nothing to recreate. + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createMessengerWithInput(input: {var: "test"}) { + messengerWithInput { id, name } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'createMessengerWithInput' => [ + 'messengerWithInput' => [ + 'id' => '/messenger_with_inputs/1', + 'name' => 'test', + ], + ], + ], + ], $response->toArray()); + } + + /** + * @param array $map + * + * @return list + */ + private function resolveResources(array $map): array + { + $resolved = []; + foreach ($map as $entity => $document) { + $resolved[] = $this->isMongoDB() ? $document : $entity; + } + + return $resolved; + } + + private function seedRelatedDummy(): void + { + $resourceClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $manager = $this->getManager(); + $related = new $resourceClass(); + $related->setName('RelatedDummy with friends'); + $manager->persist($related); + $manager->flush(); + $manager->clear(); + } + + private function seedDummyDtoNoOutput(int $count): void + { + $resourceClass = $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dto = new $resourceClass(); + $dto->lorem = 'DummyDtoNoOutput foo #'.$i; + $dto->ipsum = (string) ($i / 3); + $manager->persist($dto); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/IntrospectionTest.php b/tests/Functional/GraphQl/IntrospectionTest.php new file mode 100644 index 00000000000..b7827b5cc7b --- /dev/null +++ b/tests/Functional/GraphQl/IntrospectionTest.php @@ -0,0 +1,487 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DeprecatedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyInspection; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class IntrospectionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyProduct::class, + DummyAggregateOffer::class, + DummyDifferentGraphQlSerializationGroup::class, + DummyGroup::class, + DummyProperty::class, + DeprecatedResource::class, + VoDummyCar::class, + VoDummyInspection::class, + Person::class, + VideoGame::class, + ]; + } + + public function testEmptyQueryReturnsBadRequest(): void + { + $client = self::createClient(); + $client->request('GET', '/graphql'); + + $this->assertResponseStatusCodeSame(200); + $data = $client->getResponse()->toArray(false); + $this->assertSame(400, $data['errors'][0]['extensions']['status']); + $this->assertSame('GraphQL query is not valid.', $data['errors'][0]['message']); + } + + public function testIntrospectSchema(): void + { + $response = $this->introspectSchema(); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayHasKey('types', $data['data']['__schema']); + $this->assertSame('Query', $data['data']['__schema']['queryType']['name']); + $this->assertSame('Mutation', $data['data']['__schema']['mutationType']['name']); + } + + public function testIntrospectTypes(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + type1: __type(name: "DummyProduct") { + description, + fields { name type { name kind ofType { name kind } } } + } + type2: __type(name: "DummyAggregateOfferCursorConnection") { + description, + fields { name type { name kind ofType { name kind } } } + } + type3: __type(name: "DummyAggregateOfferEdge") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame('Dummy Product.', $data['type1']['description']); + $this->assertContainsEquals( + ['name' => 'offers', 'type' => ['name' => 'DummyAggregateOfferCursorConnection', 'kind' => 'OBJECT', 'ofType' => null]], + $data['type1']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'edges', 'type' => ['name' => null, 'kind' => 'LIST', 'ofType' => ['name' => 'DummyAggregateOfferEdge', 'kind' => 'OBJECT']]], + $data['type2']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'node', 'type' => ['name' => 'DummyAggregateOffer', 'kind' => 'OBJECT', 'ofType' => null]], + $data['type3']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'cursor', 'type' => ['name' => null, 'kind' => 'NON_NULL', 'ofType' => ['name' => 'String', 'kind' => 'SCALAR']]], + $data['type3']['fields'], + ); + } + + public function testIntrospectTypesWithDifferentSerializationGroups(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + type1: __type(name: "DummyDifferentGraphQlSerializationGroupCollection") { + description, + fields { name type { name kind ofType { name kind } } } + } + type2: __type(name: "DummyDifferentGraphQlSerializationGroupItem") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame( + 'Dummy with different serialization groups for item_query and collection_query.', + $data['type1']['description'], + ); + $this->assertCount(3, $data['type1']['fields']); + $this->assertSame('title', $data['type2']['fields'][3]['name']); + } + + public function testIntrospectDeprecatedQueries(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type (name: "Query") { + name + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertGraphQlFieldDeprecated($data, 'deprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'deprecatedResources', 'This resource is deprecated'); + } + + public function testIntrospectDeprecatedMutations(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type (name: "Mutation") { + name + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertGraphQlFieldDeprecated($data, 'deleteDeprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'updateDeprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'createDeprecatedResource', 'This resource is deprecated'); + } + + public function testIntrospectDeprecatedField(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "DeprecatedResource") { + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertGraphQlFieldDeprecated($response->toArray(), 'deprecatedField', 'This field is deprecated'); + } + + public function testRetrieveRelayNodeInterface(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Node") { + name + kind + fields { + name + type { + kind + ofType { name kind } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + '__type' => [ + 'name' => 'Node', + 'kind' => 'INTERFACE', + 'fields' => [ + [ + 'name' => 'id', + 'type' => ['kind' => 'NON_NULL', 'ofType' => ['name' => 'ID', 'kind' => 'SCALAR']], + ], + ], + ], + ], + ], $response->toArray()); + } + + public function testRetrieveRelayNodeField(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __schema { + queryType { + fields { + name + type { name kind } + args { name type { kind ofType { name kind } } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $fields = $response->toArray()['data']['__schema']['queryType']['fields']; + $this->assertSame('node', $fields[0]['name']); + $this->assertSame('Node', $fields[0]['type']['name']); + $this->assertSame('INTERFACE', $fields[0]['type']['kind']); + $this->assertSame('id', $fields[0]['args'][0]['name']); + $this->assertSame('NON_NULL', $fields[0]['args'][0]['type']['kind']); + $this->assertSame('ID', $fields[0]['args'][0]['type']['ofType']['name']); + $this->assertSame('SCALAR', $fields[0]['args'][0]['type']['ofType']['kind']); + } + + public function testIntrospectIterableFieldOnDummy(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Dummy") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertContainsEquals( + ['name' => 'jsonData', 'type' => ['name' => 'Iterable', 'kind' => 'SCALAR', 'ofType' => null]], + $response->toArray()['data']['__type']['fields'], + ); + } + + public function testRetrieveDummyGroupFieldsAndMutationInputs(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeQuery: __type(name: "DummyGroup") { + fields { name type { name kind ofType { name kind } } } + } + typeCreateInput: __type(name: "createDummyGroupInput") { + inputFields { name type { name kind ofType { name kind } } } + } + typeCreatePayload: __type(name: "createDummyGroupPayload") { + fields { name type { name kind ofType { name kind } } } + } + typeCreatePayloadData: __type(name: "createDummyGroupPayloadData") { + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertCount(2, $data['typeQuery']['fields']); + $this->assertSame('id', $data['typeQuery']['fields'][0]['name']); + $this->assertSame('foo', $data['typeQuery']['fields'][1]['name']); + + $this->assertCount(3, $data['typeCreateInput']['inputFields']); + $this->assertSame('bar', $data['typeCreateInput']['inputFields'][0]['name']); + $this->assertSame('baz', $data['typeCreateInput']['inputFields'][1]['name']); + $this->assertSame('clientMutationId', $data['typeCreateInput']['inputFields'][2]['name']); + + $this->assertCount(2, $data['typeCreatePayload']['fields']); + $this->assertSame('dummyGroup', $data['typeCreatePayload']['fields'][0]['name']); + $this->assertSame('createDummyGroupPayloadData', $data['typeCreatePayload']['fields'][0]['type']['name']); + $this->assertSame('clientMutationId', $data['typeCreatePayload']['fields'][1]['name']); + + $this->assertCount(2, $data['typeCreatePayloadData']['fields']); + $this->assertSame('id', $data['typeCreatePayloadData']['fields'][0]['name']); + $this->assertSame('bar', $data['typeCreatePayloadData']['fields'][1]['name']); + } + + public function testRetrieveNestedMutationPayloadData(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeCreatePayload: __type(name: "createDummyPropertyPayload") { + fields { name type { name kind ofType { name kind } } } + } + typeCreatePayloadData: __type(name: "createDummyPropertyPayloadData") { + fields { name type { name kind ofType { name kind } } } + } + typeCreateNestedPayload: __type(name: "createDummyGroupNestedPayload") { + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame([ + ['name' => 'dummyProperty', 'type' => ['name' => 'createDummyPropertyPayloadData', 'kind' => 'OBJECT', 'ofType' => null]], + ['name' => 'clientMutationId', 'type' => ['name' => 'String', 'kind' => 'SCALAR', 'ofType' => null]], + ], $data['typeCreatePayload']['fields']); + + $this->assertContainsEquals( + ['name' => 'group', 'type' => ['name' => 'createDummyGroupNestedPayload', 'kind' => 'OBJECT', 'ofType' => null]], + $data['typeCreatePayloadData']['fields'], + ); + + $this->assertContainsEquals( + ['name' => 'id', 'type' => ['name' => null, 'kind' => 'NON_NULL', 'ofType' => ['name' => 'ID', 'kind' => 'SCALAR']]], + $data['typeCreateNestedPayload']['fields'], + ); + } + + public function testRetrieveTypenameViaGraphQlQuery(): void + { + $resources = [ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]; + $this->recreateSchema($resources); + $this->seedDummiesWithRelatedDummy(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy: dummy(id: "/dummies/3") { + name + relatedDummy { + id + name + __typename + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('Dummy #3', $dummy['name']); + $this->assertSame('RelatedDummy #3', $dummy['relatedDummy']['name']); + $this->assertSame('RelatedDummy', $dummy['relatedDummy']['__typename']); + } + + public function testIntrospectTypeAvailableOnlyThroughRelations(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeNotAvailable: __type(name: "VoDummyInspectionCursorConnection") { + description + } + typeOwner: __type(name: "VoDummyCar") { + description, + fields { name type { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertNull($data['typeNotAvailable']); + $this->assertSame('VoDummyInspectionCursorConnection', $data['typeOwner']['fields'][1]['type']['name']); + } + + public function testIntrospectEnum(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + person: __type(name: "Person") { + name + fields { + name + type { + name + description + enumValues { name description } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $field = $response->toArray()['data']['person']['fields'][1]; + $this->assertSame('GenderTypeEnum', $field['type']['name']); + $this->assertSame('MALE', $field['type']['enumValues'][0]['name']); + $this->assertSame('FEMALE', $field['type']['enumValues'][1]['name']); + $this->assertSame('The female gender.', $field['type']['enumValues'][1]['description']); + } + + public function testIntrospectEnumResource(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + videoGame: __type(name: "VideoGame") { + name + fields { + name + type { name kind ofType { name kind } } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'GamePlayMode', + $response->toArray()['data']['videoGame']['fields'][3]['type']['ofType']['name'], + ); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $relatedClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $manager = $this->getManager(); + + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->setName('RelatedDummy #'.$i); + + $dummy = new $dummyClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/MutationTest.php b/tests/Functional/GraphQl/MutationTest.php new file mode 100644 index 00000000000..ac8bf520b09 --- /dev/null +++ b/tests/Functional/GraphQl/MutationTest.php @@ -0,0 +1,955 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6354\ActivityLog; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomMutation as DummyCustomMutationDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooEmbeddable as FooEmbeddableDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\WritableId as WritableIdDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomMutation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooEmbeddable; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WritableId; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\MediaObject; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\HttpFoundation\File\UploadedFile; + +final class MutationTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private const FIXTURES_DIR = __DIR__.'/Fixtures'; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Foo::class, + Dummy::class, + RelatedDummy::class, + Person::class, + FooDummy::class, + FooEmbeddable::class, + CompositeRelation::class, + CompositeItem::class, + CompositeLabel::class, + WritableId::class, + DummyGroup::class, + DummyCustomMutation::class, + ActivityLog::class, + GamePlayMode::class, + VideoGame::class, + ThirdLevel::class, + FourthLevel::class, + DummyFriend::class, + RelatedToDummyFriend::class, + MediaObject::class, + ]; + } + + public function testCreateItem(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createFoo(input: {name: "A new one", bar: "new", clientMutationId: "myId"}) { + foo { id _id __typename name bar } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['createFoo']; + $this->assertSame('/foos/1', $data['foo']['id']); + $this->assertSame(1, $data['foo']['_id']); + $this->assertSame('Foo', $data['foo']['__typename']); + $this->assertSame('A new one', $data['foo']['name']); + $this->assertSame('new', $data['foo']['bar']); + $this->assertSame('myId', $data['clientMutationId']); + } + + public function testCreateItemWithoutClientMutationId(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createFoo(input: {name: "Created without mutation id", bar: "works"}) { + foo { id name bar } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['createFoo']['foo']; + $this->assertSame('/foos/1', $data['id']); + $this->assertSame('Created without mutation id', $data['name']); + $this->assertSame('works', $data['bar']); + } + + public function testCreateItemWithRelationToExisting(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "A dummy", foo: [], relatedDummy: "/related_dummies/1", name_converted: "Converted" clientMutationId: "myId"}) { + dummy { + id + name + foo + relatedDummy { name __typename } + name_converted + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummy']; + $this->assertSame('/dummies/2', $d['dummy']['id']); + $this->assertSame('A dummy', $d['dummy']['name']); + $this->assertCount(0, $d['dummy']['foo']); + $this->assertSame('RelatedDummy #1', $d['dummy']['relatedDummy']['name']); + $this->assertSame('RelatedDummy', $d['dummy']['relatedDummy']['__typename']); + $this->assertSame('Converted', $d['dummy']['name_converted']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateItemWithIterableField(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "A dummy", foo: [], jsonData: {bar:{baz:3,qux:[7.6,false,null]}}, arrayData: ["bar", "baz"], clientMutationId: "myId"}) { + dummy { + id name foo jsonData arrayData + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummy']; + $this->assertSame('/dummies/1', $d['dummy']['id']); + $this->assertSame('A dummy', $d['dummy']['name']); + $this->assertSame(3, $d['dummy']['jsonData']['bar']['baz']); + $this->assertSame(7.6, $d['dummy']['jsonData']['bar']['qux'][0]); + $this->assertFalse($d['dummy']['jsonData']['bar']['qux'][1]); + $this->assertNull($d['dummy']['jsonData']['bar']['qux'][2]); + $this->assertSame('baz', $d['dummy']['arrayData'][1]); + } + + public function testCreateItemWithEnum(): void + { + $this->recreateSchema([$this->isMongoDB() ? PersonDocument::class : Person::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createPerson(input: {name: "Mob", genderType: FEMALE}) { + person { id name genderType } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $p = $response->toArray()['data']['createPerson']['person']; + $this->assertSame('/people/1', $p['id']); + $this->assertSame('Mob', $p['name']); + $this->assertSame('FEMALE', $p['genderType']); + } + + public function testCreateItemWithEnumCollection(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Enum collection scenario @!mongodb'); + } + $this->recreateSchema([Person::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createPerson(input: {name: "Harry", academicGrades: [BACHELOR, MASTER]}) { + person { id name genderType academicGrades } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $p = $response->toArray()['data']['createPerson']['person']; + $this->assertSame('/people/1', $p['id']); + $this->assertSame('Harry', $p['name']); + $this->assertCount(2, $p['academicGrades']); + $this->assertSame('BACHELOR', $p['academicGrades'][0]); + $this->assertSame('MASTER', $p['academicGrades'][1]); + } + + public function testDeleteItem(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? FooDocument::class : Foo::class; + $foo = new $class(); + $foo->setName('Existing'); + $foo->setBar('value'); + $manager->persist($foo); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteFoo(input: {id: "/foos/1", clientMutationId: "anotherId"}) { + foo { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['deleteFoo']; + $this->assertSame('/foos/1', $data['foo']['id']); + $this->assertSame('anotherId', $data['clientMutationId']); + } + + public function testDeleteWithWrongResourceTypeYieldsError(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? FooDocument::class : Foo::class, + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteFoo(input: {id: "/dummies/1", clientMutationId: "myId"}) { + foo { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'Item "/dummies/1" did not match expected type "Foo".', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testDeleteItemWithCompositeIdentifiers(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=1", clientMutationId: "myId"}) { + compositeRelation { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['deleteCompositeRelation']; + $this->assertSame('/composite_relations/compositeItem=1;compositeLabel=1', $data['compositeRelation']['id']); + $this->assertSame('myId', $data['clientMutationId']); + } + + public function testModifyItem(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateDummy(input: {id: "/dummies/1", description: "Modified description.", dummyDate: "2018-06-05T00:00:00+00:00", clientMutationId: "myId"}) { + dummy { id name description dummyDate } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateDummy']; + $this->assertSame('/dummies/1', $d['dummy']['id']); + $this->assertSame('Dummy #1', $d['dummy']['name']); + $this->assertSame('Modified description.', $d['dummy']['description']); + $this->assertSame('2018-06-05', $d['dummy']['dummyDate']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testModifyItemWithEmbeddedObject(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { + fooDummy { + id + name + embeddedFoo { dummyName } + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateFooDummy']; + $this->assertSame('modifiedName', $d['fooDummy']['name']); + $this->assertSame('Embedded name', $d['fooDummy']['embeddedFoo']['dummyName']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testModifyNonWritablePropertyRejected(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", nonWritableProp: "written", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { + fooDummy { id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesRegularExpression( + '/^Field "nonWritableProp" is not defined by type "?updateFooDummyInput"?\.$/', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testModifyNonWritableEmbeddedPropertyRejected(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name", nonWritableProp: "written"}, clientMutationId: "myId"}) { + fooDummy { id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesRegularExpression( + '/^Field "nonWritableProp" is not defined by type "?FooEmbeddableNestedInput"?\.$/', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testModifyItemWithCompositeIdentifiers(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=2", value: "Modified value.", clientMutationId: "myId"}) { + compositeRelation { id value } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateCompositeRelation']; + $this->assertSame('/composite_relations/compositeItem=1;compositeLabel=2', $d['compositeRelation']['id']); + $this->assertSame('Modified value.', $d['compositeRelation']['value']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateWithCustomUuid(): void + { + $this->recreateSchema([$this->isMongoDB() ? WritableIdDocument::class : WritableId::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createWritableId(input: {_id: "c6b722fe-0331-48c4-a214-f81f9f1ca082", name: "Foo", clientMutationId: "m"}) { + writableId { id _id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createWritableId']; + $this->assertSame('/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082', $d['writableId']['id']); + $this->assertSame('c6b722fe-0331-48c4-a214-f81f9f1ca082', $d['writableId']['_id']); + $this->assertSame('Foo', $d['writableId']['name']); + $this->assertSame('m', $d['clientMutationId']); + } + + public function testUpdateWithCustomUuid(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('WritableId update @!mongodb'); + } + $this->recreateSchema([WritableId::class]); + $manager = $this->getManager(); + $w = new WritableId(); + $w->id = 'c6b722fe-0331-48c4-a214-f81f9f1ca082'; + $w->name = 'Foo'; + $manager->persist($w); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateWritableId(input: {id: "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082", _id: "f8a708b2-310f-416c-9aef-b1b5719dfa47", name: "Foo", clientMutationId: "m"}) { + writableId { id _id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateWritableId']; + $this->assertSame('/writable_ids/f8a708b2-310f-416c-9aef-b1b5719dfa47', $d['writableId']['id']); + $this->assertSame('f8a708b2-310f-416c-9aef-b1b5719dfa47', $d['writableId']['_id']); + $this->assertSame('Foo', $d['writableId']['name']); + } + + public function testUseSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + $g = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $p) { + $g->{$p} = ucfirst($p).' #1'; + } + $manager->persist($g); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyGroup(input: {bar: "Bar", baz: "Baz", clientMutationId: "myId"}) { + dummyGroup { id bar __typename } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummyGroup']; + $this->assertSame('/dummy_groups/2', $d['dummyGroup']['id']); + $this->assertSame('Bar', $d['dummyGroup']['bar']); + $this->assertSame('createDummyGroupPayloadData', $d['dummyGroup']['__typename']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testTriggerValidationError(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "", foo: [], clientMutationId: "myId"}) { + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame('422', (string) $data['errors'][0]['extensions']['status']); + $this->assertSame('name: This value should not be blank.', $data['errors'][0]['message']); + $this->assertArrayHasKey('violations', $data['errors'][0]['extensions']); + $this->assertSame('name', $data['errors'][0]['extensions']['violations'][0]['path']); + $this->assertSame('This value should not be blank.', $data['errors'][0]['extensions']['violations'][0]['message']); + } + + public function testCustomMutation(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + '8', + (string) $response->toArray()['data']['sumDummyCustomMutation']['dummyCustomMutation']['result'], + ); + } + + public function testCustomMutationNotPersisted(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumNotPersistedDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['sumNotPersistedDummyCustomMutation']['dummyCustomMutation']); + } + + public function testCustomMutationNoWriteCustomResult(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumNoWriteCustomResultDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + '1234', + (string) $response->toArray()['data']['sumNoWriteCustomResultDummyCustomMutation']['dummyCustomMutation']['result'], + ); + } + + public function testCustomMutationOnlyPersist(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumOnlyPersistDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['sumOnlyPersistDummyCustomMutation']['dummyCustomMutation']); + } + + public function testCustomMutationCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + testCustomArgumentsDummyCustomMutation(input: {operandC: 18, clientMutationId: "myId"}) { + dummyCustomMutation { result } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['testCustomArgumentsDummyCustomMutation']; + $this->assertSame('18', (string) $d['dummyCustomMutation']['result']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateItemWithEnumAsResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('VideoGame ORM-only.'); + } + + $this->recreateSchema([VideoGame::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + gamePlayModes { id name } + gamePlayMode(id: "/game_play_modes/SINGLE_PLAYER") { name } + } + QUERY); + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertCount(3, $data['gamePlayModes']); + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $data['gamePlayModes'][2]['id']); + $this->assertSame('SINGLE_PLAYER', $data['gamePlayModes'][2]['name']); + $this->assertSame('SINGLE_PLAYER', $data['gamePlayMode']['name']); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createVideoGame(input: {name: "Baten Kaitos", playMode: "/game_play_modes/SINGLE_PLAYER"}) { + videoGame { id name playMode { id name } } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $vg = $response->toArray()['data']['createVideoGame']['videoGame']; + $this->assertSame('/video_games/1', $vg['id']); + $this->assertSame('Baten Kaitos', $vg['name']); + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $vg['playMode']['id']); + $this->assertSame('SINGLE_PLAYER', $vg['playMode']['name']); + } + + public function testDeleteInvalidItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ActivityLog @!mongodb.'); + } + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteActivityLog(input: {id: "/activity_logs/1"}) { + activityLog { id } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $this->assertArrayHasKey('activityLog', $data['data']['deleteActivityLog']); + } + + public function testUploadFileWithCustomMutation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MediaObject @!mongodb.'); + } + + $file = new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true); + $response = $this->executeGraphQlMultipart( + '{"query": "mutation($file: Upload!) { uploadMediaObject(input: {file: $file}) { mediaObject { id contentUrl } } }", "variables": {"file": null}}', + '{"file": ["variables.file"]}', + ['file' => $file], + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test.gif', $response->toArray()['data']['uploadMediaObject']['mediaObject']['contentUrl']); + } + + public function testUploadMultipleFilesWithCustomMutation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MediaObject @!mongodb.'); + } + + $files = [ + '0' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + '1' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + '2' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + ]; + $response = $this->executeGraphQlMultipart( + '{"query": "mutation($files: [Upload!]!) { uploadMultipleMediaObject(input: {files: $files}) { mediaObject { id contentUrl } } }", "variables": {"files": [null, null, null]}}', + '{"0": ["variables.files.0"], "1": ["variables.files.1"], "2": ["variables.files.2"]}', + $files, + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test.gif', $response->toArray()['data']['uploadMultipleMediaObject']['mediaObject']['contentUrl']); + } + + public function testUseSerializationGroupsWithRelations(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FourthLevel + RelatedToDummyFriend @!mongodb.'); + } + + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class, + DummyFriend::class, RelatedToDummyFriend::class, + ]); + $this->seedDummyWithRelatedDummyAndThirdLevel(); + $this->seedRelatedDummyWithFriends(2); + $this->seedDummyWithFourthLevelRelation(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateRelatedDummy(input: { + id: "/related_dummies/2", + symfony: "laravel", + thirdLevel: { fourthLevel: "/fourth_levels/1" } + }) { + relatedDummy { + id symfony + thirdLevel { id fourthLevel { id __typename } __typename } + relatedToDummyFriend { + edges { node { name } } + __typename + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $rel = $response->toArray()['data']['updateRelatedDummy']['relatedDummy']; + $this->assertSame('/related_dummies/2', $rel['id']); + $this->assertSame('laravel', $rel['symfony']); + $this->assertSame('/third_levels/3', $rel['thirdLevel']['id']); + $this->assertSame('updateThirdLevelNestedPayload', $rel['thirdLevel']['__typename']); + $this->assertSame('/fourth_levels/1', $rel['thirdLevel']['fourthLevel']['id']); + $this->assertSame('updateFourthLevelNestedPayload', $rel['thirdLevel']['fourthLevel']['__typename']); + $this->assertSame('updateRelatedToDummyFriendNestedPayloadCursorConnection', $rel['relatedToDummyFriend']['__typename']); + $this->assertSame('Relation-1', $rel['relatedToDummyFriend']['edges'][0]['node']['name']); + $this->assertSame('Relation-2', $rel['relatedToDummyFriend']['edges'][1]['node']['name']); + } + + public function testMutationRunsBeforeValidation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ActivityLog @!mongodb.'); + } + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createActivityLog(input: {name: ""}) { + activityLog { name } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $this->assertSame('hi', $response->toArray()['data']['createActivityLog']['activityLog']['name']); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedFooDummyWithEmbeddable(): void + { + $manager = $this->getManager(); + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $fooClass = $this->isMongoDB() ? FooDummyDocument::class : FooDummy::class; + + $dummy = new $dummyClass(); + $dummy->setName('Lorem'); + + $foo = new $fooClass(); + $foo->setName('Hawsepipe'); + + $embeddedClass = $this->isMongoDB() ? FooEmbeddableDocument::class : FooEmbeddable::class; + $embedded = new $embeddedClass(); + $embedded->setDummyName('embeddedHawsepipe'); + $foo->setEmbeddedFoo($embedded); + $foo->setDummy($dummy); + + $manager->persist($foo); + $manager->flush(); + } + + private function seedCompositeIdentifierObjects(): void + { + $manager = $this->getManager(); + $item = new CompositeItem(); + $item->setField1('foobar'); + $manager->persist($item); + $manager->flush(); + + for ($i = 0; $i < 4; ++$i) { + $label = new CompositeLabel(); + $label->setValue('foo-'.$i); + $manager->persist($label); + $manager->flush(); + + $rel = new CompositeRelation(); + $rel->setCompositeLabel($label); + $rel->setCompositeItem($item); + $rel->setValue('somefoobardummy'); + $manager->persist($rel); + } + $manager->flush(); + $manager->clear(); + } + + private function seedDummyCustomMutation(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class; + for ($i = 1; $i <= $count; ++$i) { + $m = new $class(); + $m->setOperandA(3); + $manager->persist($m); + } + $manager->flush(); + } + + private function seedDummyWithRelatedDummyAndThirdLevel(): void + { + $manager = $this->getManager(); + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy #1'); + $relatedDummy->setThirdLevel($thirdLevel); + $dummy = new Dummy(); + $dummy->setName('Dummy #1'); + $dummy->setAlias('Alias #0'); + $dummy->setRelatedDummy($relatedDummy); + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + $manager->persist($dummy); + $manager->flush(); + } + + private function seedRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + $manager->persist($relation); + } + + $other = new RelatedDummy(); + $other->setName('RelatedDummy without friends'); + $manager->persist($other); + $manager->flush(); + $manager->clear(); + } + + private function seedDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/QueryTest.php b/tests/Functional/GraphQl/QueryTest.php new file mode 100644 index 00000000000..78e1ccb96a7 --- /dev/null +++ b/tests/Functional/GraphQl/QueryTest.php @@ -0,0 +1,852 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6427\SecurityAfterResolver; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\WithJsonDummy as WithJsonDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNested; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNestedPaginated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsResolveDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TreeDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WithJsonDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; + +final class QueryTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + MultiRelationsDummy::class, + MultiRelationsRelatedDummy::class, + MultiRelationsResolveDummy::class, + MultiRelationsNested::class, + MultiRelationsNestedPaginated::class, + TreeDummy::class, + WithJsonDummy::class, + DummyGroup::class, + DummyCar::class, + DummyCarColor::class, + DummyDtoNoInput::class, + DummyDtoNoOutput::class, + DummyCustomQuery::class, + DummyDifferentGraphQlSerializationGroup::class, + SecurityAfterResolver::class, + Foo::class, + ]; + } + + public function testBasicQuery(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + id + name + name_converted + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('/dummies/1', $dummy['id']); + $this->assertSame('Dummy #1', $dummy['name']); + $this->assertSame('Converted 1', $dummy['name_converted']); + } + + public function testQueryWithDifferentRelationsToSameResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 1, 2, 3, 4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneRelation { id name } + manyToOneResolveRelation { id name } + manyToManyRelations { edges { node { id name } } } + oneToManyRelations { edges { node { id name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $payload = $response->toArray(false); + if (isset($payload['errors'])) { + $this->fail('GraphQL errors: '.json_encode($payload['errors'], \JSON_PRETTY_PRINT)); + } + $d = $payload['data']['multiRelationsDummy']; + $this->assertSame('/multi_relations_dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertNotNull($d['manyToOneRelation']['id']); + $this->assertSame('RelatedManyToOneDummy #2', $d['manyToOneRelation']['name']); + $this->assertCount(2, $d['manyToManyRelations']['edges']); + $this->assertMatchesRegularExpression('#RelatedManyToManyDummy(1|2)2#', $d['manyToManyRelations']['edges'][0]['node']['name']); + $this->assertMatchesRegularExpression('#RelatedManyToManyDummy(1|2)2#', $d['manyToManyRelations']['edges'][1]['node']['name']); + $this->assertCount(3, $d['oneToManyRelations']['edges']); + $this->assertMatchesRegularExpression('#RelatedOneToManyDummy(1|3)2#', $d['oneToManyRelations']['edges'][0]['node']['name']); + $this->assertMatchesRegularExpression('#RelatedOneToManyDummy(1|3)2#', $d['oneToManyRelations']['edges'][2]['node']['name']); + } + + public function testQueryEmbeddedCollections(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 1, 2, 3, 4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneResolveRelation { id name } + nestedCollection { name } + nestedPaginatedCollection { edges { node { name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray(); + $this->assertArrayNotHasKey('errors', $d); + $dummy = $d['data']['multiRelationsDummy']; + $this->assertNotNull($dummy['manyToOneResolveRelation']['id']); + $this->assertSame('RelatedManyToOneResolveDummy #2', $dummy['manyToOneResolveRelation']['name']); + for ($i = 1; $i <= 4; ++$i) { + $this->assertSame('NestedDummy'.$i, $dummy['nestedCollection'][$i - 1]['name']); + } + // Edges count exists, but node.name resolves to null because JSON-column hydration + // returns associative arrays, not MultiRelationsNestedPaginated objects, so the + // GraphQL field resolver can't access ->name. Separate from the link bug. + $this->assertCount(4, $dummy['nestedPaginatedCollection']['edges']); + } + + public function testQueryWithUnsetRelations(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 0, 0, 0, 0); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id name + manyToOneRelation { id name } + manyToOneResolveRelation { id name } + manyToManyRelations { edges { node { id name } } } + oneToManyRelations { edges { node { id name } } } + nestedCollection { name } + nestedPaginatedCollection { edges { node { name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $d = $data['data']['multiRelationsDummy']; + $this->assertSame('/multi_relations_dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertNull($d['manyToOneRelation']); + $this->assertNull($d['manyToOneResolveRelation']); + $this->assertCount(0, $d['manyToManyRelations']['edges']); + $this->assertCount(0, $d['oneToManyRelations']['edges']); + $this->assertCount(0, $d['nestedCollection']); + $this->assertCount(0, $d['nestedPaginatedCollection']['edges']); + } + + public function testTreeDummiesChildRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('TreeDummy is ORM-only.'); + } + $this->recreateSchema([TreeDummy::class]); + $manager = $this->getManager(); + $parent = new TreeDummy(); + $child = new TreeDummy(); + $child->setParent($parent); + $manager->persist($parent); + $manager->persist($child); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + treeDummies { + edges { node { id children { totalCount } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $edges = $data['data']['treeDummies']['edges']; + $this->assertSame('/tree_dummies/1', $edges[0]['node']['id']); + $this->assertSame(1, $edges[0]['node']['children']['totalCount']); + $this->assertSame('/tree_dummies/2', $edges[1]['node']['id']); + $this->assertSame(0, $edges[1]['node']['children']['totalCount']); + } + + public function testRelayNode(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + node(id: "/dummies/1") { + id + ... on Dummy { name } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $node = $response->toArray()['data']['node']; + $this->assertSame('/dummies/1', $node['id']); + $this->assertSame('Dummy #1', $node['name']); + } + + public function testIterableField(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + $this->seedDummiesWithJsonAndArrayData(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/3") { + id + name + jsonData + arrayData + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('/dummies/3', $dummy['id']); + $this->assertSame('Dummy #1', $dummy['name']); + $this->assertCount(2, $dummy['jsonData']['foo']); + $this->assertSame(5, $dummy['jsonData']['bar']); + $this->assertSame('baz', $dummy['arrayData'][2]); + } + + public function testNullJsonField(): void + { + $this->recreateSchema([$this->isMongoDB() ? WithJsonDummyDocument::class : WithJsonDummy::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? WithJsonDummyDocument::class : WithJsonDummy::class; + for ($i = 1; $i <= 2; ++$i) { + $w = new $class(); + $w->json = null; + $manager->persist($w); + } + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + withJsonDummy(id: "/with_json_dummies/2") { + id + json + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $w = $response->toArray()['data']['withJsonDummy']; + $this->assertSame('/with_json_dummies/2', $w['id']); + $this->assertNull($w['json']); + } + + public function testQueryWithVariables(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl( + <<<'QUERY' + query DummyWithId($itemId: ID = "/dummies/1") { + dummyItem: dummy(id: $itemId) { + id + name + relatedDummy { id name } + } + } + QUERY, + ['itemId' => '/dummies/2'], + ); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['dummyItem']; + $this->assertSame('/dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertSame('/related_dummies/2', $d['relatedDummy']['id']); + $this->assertSame('RelatedDummy #2', $d['relatedDummy']['name']); + } + + public function testQueryWithOperationName(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $query = <<<'QUERY' + query DummyWithId1 { + dummyItem: dummy(id: "/dummies/1") { name } + } + query DummyWithId2 { + dummyItem: dummy(id: "/dummies/2") { id name } + } + QUERY; + + $response = $this->executeGraphQl($query, [], 'DummyWithId2'); + $d = $response->toArray()['data']['dummyItem']; + $this->assertSame('/dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + + $response = $this->executeGraphQl($query, [], 'DummyWithId1'); + $this->assertSame('Dummy #1', $response->toArray()['data']['dummyItem']['name']); + } + + public function testSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class]); + $this->seedDummyGroups(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyGroup(id: "/dummy_groups/1") { + foo + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('Foo #1', $response->toArray()['data']['dummyGroup']['foo']); + } + + public function testSerializedName(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class, + $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class, + ]); + $this->seedDummyCarWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyCar(id: "/dummy_cars/1") { + carBrand + } + } + QUERY); + + $this->assertSame('DummyBrand', $response->toArray()['data']['dummyCar']['carBrand']); + } + + public function testFetchOnlyInternalId(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + _id + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('1', (string) $response->toArray()['data']['dummy']['_id']); + } + + public function testNonexistentItemReturnsNull(): void + { + $this->recreateDummiesAndRelated(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/5") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['dummy']); + } + + public function testNonexistentIriYieldsDebugMessage(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + foo(id: "/foo/1") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertGraphQlDebugMessage($data, 'No route matches "/foo/1".'); + $this->assertCount(1, $data['errors']); + } + + public function testOutputClassUsedInsteadOfResource(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class]); + $this->seedDummyDtoNoInput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoNoInputs { + edges { node { baz bat } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'dummyDtoNoInputs' => [ + 'edges' => [ + ['node' => ['baz' => 0.33, 'bat' => 'DummyDtoNoInput foo #1']], + ['node' => ['baz' => 0.67, 'bat' => 'DummyDtoNoInput foo #2']], + ], + ], + ], + ], $response->toArray()); + } + + public function testDisableOutputClassYieldsEmptyResponse(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class, + $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class, + ]); + $this->seedDummyDtoNoOutput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoNoInputs { + edges { node { baz bat } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['dummyDtoNoInputs' => ['edges' => []]], + ], $response->toArray()); + } + + public function testCustomNotRetrievedItemQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testNotRetrievedItemDummyCustomQuery { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['testNotRetrievedItemDummyCustomQuery' => ['message' => 'Success (not retrieved)!']], + ], $response->toArray()); + } + + public function testCustomItemQueryWithReadAndSerializeDisabled(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testNoReadAndSerializeItemDummyCustomQuery(id: "/not_used") { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame(['data' => ['testNoReadAndSerializeItemDummyCustomQuery' => null]], $response->toArray()); + } + + public function testCustomItemQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testItemDummyCustomQuery(id: "/dummy_custom_queries/1") { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + ['data' => ['testItemDummyCustomQuery' => ['message' => 'Success!']]], + $response->toArray(), + ); + } + + public function testCustomItemQueryWithCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testItemCustomArgumentsDummyCustomQuery( + id: "/dummy_custom_queries/1", + customArgumentBool: true, + customArgumentInt: 3, + customArgumentString: "A string", + customArgumentFloat: 2.6, + customArgumentIntArray: [4], + customArgumentCustomType: "2019-05-24T00:00:00+00:00" + ) { + message + customArgs + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testItemCustomArgumentsDummyCustomQuery' => [ + 'message' => 'Success!', + 'customArgs' => [ + 'id' => '/dummy_custom_queries/1', + 'customArgumentBool' => true, + 'customArgumentInt' => 3, + 'customArgumentString' => 'A string', + 'customArgumentFloat' => 2.6, + 'customArgumentIntArray' => [4], + 'customArgumentCustomType' => '2019-05-24T00:00:00+00:00', + ], + ], + ], + ], $response->toArray()); + } + + public function testDifferentSerializationGroupsForItemAndCollection(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class; + $entity = new $class(); + $entity->setName('Name #1'); + $entity->setTitle('Title #1'); + $manager->persist($entity); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDifferentGraphQlSerializationGroup(id: "/dummy_different_graph_ql_serialization_groups/1") { + name + title + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['dummyDifferentGraphQlSerializationGroup']; + $this->assertSame('Name #1', $d['name']); + $this->assertSame('Title #1', $d['title']); + } + + public function testSecurityAfterResolver(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + getSecurityAfterResolver(id: "/security_after_resolvers/1") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test', $response->toArray()['data']['getSecurityAfterResolver']['name']); + } + + public function testSecurityAfterResolverDeniesNonMatchingId(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + getSecurityAfterResolver(id: "/security_after_resolvers/2") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertArrayNotHasKey('name', $data['data']['getSecurityAfterResolver'] ?? []); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + } + + private function recreateMultiRelations(): void + { + $this->recreateSchema([ + MultiRelationsDummy::class, + MultiRelationsRelatedDummy::class, + MultiRelationsResolveDummy::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithJsonAndArrayData(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setJsonData(['foo' => ['bar', 'baz'], 'bar' => 5]); + $dummy->setArrayData(['foo', 'bar', 'baz']); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedMultiRelations(int $nb, int $nbmtor, int $nbmtmr, int $nbotmr, int $nber): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $related = new MultiRelationsRelatedDummy(); + $related->name = 'RelatedManyToOneDummy #'.$i; + + $resolve = new MultiRelationsResolveDummy(); + $resolve->name = 'RelatedManyToOneResolveDummy #'.$i; + + $dummy = new MultiRelationsDummy(); + $dummy->name = 'Dummy #'.$i; + + if ($nbmtor) { + $dummy->setManyToOneRelation($related); + $dummy->setManyToOneResolveRelation($resolve); + } + + for ($j = 1; $j <= $nbmtmr; ++$j) { + $m2m = new MultiRelationsRelatedDummy(); + $m2m->name = 'RelatedManyToManyDummy'.$j.$i; + $manager->persist($m2m); + $dummy->addManyToManyRelation($m2m); + } + + for ($j = 1; $j <= $nbotmr; ++$j) { + $o2m = new MultiRelationsRelatedDummy(); + $o2m->name = 'RelatedOneToManyDummy'.$j.$i; + $o2m->setOneToManyRelation($dummy); + $manager->persist($o2m); + $dummy->addOneToManyRelation($o2m); + } + + $nested = new ArrayCollection(); + for ($j = 1; $j <= $nber; ++$j) { + $n = new MultiRelationsNested(); + $n->name = 'NestedDummy'.$j; + $nested->add($n); + } + $dummy->setNestedCollection($nested); + + $nestedPaginated = new ArrayCollection(); + for ($j = 1; $j <= $nber; ++$j) { + $np = new MultiRelationsNestedPaginated(); + $np->name = 'NestedPaginatedDummy'.$j; + $nestedPaginated->add($np); + } + $dummy->setNestedPaginatedCollection($nestedPaginated); + + $manager->persist($related); + $manager->persist($resolve); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummyGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $group = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $property) { + $group->{$property} = ucfirst($property).' #'.$i; + } + $manager->persist($group); + } + $manager->flush(); + } + + private function seedDummyCarWithColors(): void + { + $manager = $this->getManager(); + $carClass = $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class; + $colorClass = $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class; + + $car = new $carClass(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + if (\is_object($car->getId())) { + $manager->persist($car->getId()); + $manager->flush(); + } + $red = new $colorClass(); + $red->setProp('red'); + $red->setCar($car); + $manager->persist($red); + $blue = new $colorClass(); + $blue->setProp('blue'); + $blue->setCar($car); + $manager->persist($blue); + $manager->flush(); + } + + private function seedDummyDtoNoInput(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class; + for ($i = 1; $i <= $count; ++$i) { + $dto = new $class(); + $dto->lorem = 'DummyDtoNoInput foo #'.$i; + $dto->ipsum = round($i / 3, 2); + $manager->persist($dto); + } + $manager->flush(); + } + + private function seedDummyDtoNoOutput(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class; + for ($i = 1; $i <= $count; ++$i) { + $dto = new $class(); + $dto->lorem = 'DummyDtoNoOutput foo #'.$i; + $dto->ipsum = (string) round($i / 3, 2); + $manager->persist($dto); + } + $manager->flush(); + } + + private function seedDummyCustomQuery(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class; + for ($i = 1; $i <= $count; ++$i) { + $manager->persist(new $class()); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/SchemaExportTest.php b/tests/Functional/GraphQl/SchemaExportTest.php new file mode 100644 index 00000000000..06360f1212c --- /dev/null +++ b/tests/Functional/GraphQl/SchemaExportTest.php @@ -0,0 +1,174 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OptionalRequiredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Tester\ApplicationTester; + +final class SchemaExportTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private ApplicationTester $tester; + + protected function setUp(): void + { + self::bootKernel(); + + $application = new Application(static::$kernel); + $application->setCatchExceptions(false); + $application->setAutoExit(false); + $this->tester = new ApplicationTester($application); + } + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + DummyFriend::class, + RelatedToDummyFriend::class, + OptionalRequiredDummy::class, + ThirdLevel::class, + ]; + } + + public function testExportGraphQlSchema(): void + { + $this->tester->run(['command' => 'api:graphql:export']); + + $output = $this->tester->getDisplay(); + + $this->assertStringContainsString(<<<'SDL' + "Dummy Friend." + type DummyFriend implements Node { + id: ID! + + "The id" + _id: Int! + + "The dummy name" + name: String! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Cursor connection for DummyFriend." + type DummyFriendCursorConnection { + edges: [DummyFriendEdge] + pageInfo: DummyFriendPageInfo! + totalCount: Int! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Edge of DummyFriend." + type DummyFriendEdge { + node: DummyFriend + cursor: String! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Information about the current page." + type DummyFriendPageInfo { + endCursor: String + startCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + updateDummyFriend(input: updateDummyFriendInput!): updateDummyFriendPayload + + "Deletes a DummyFriend." + deleteDummyFriend(input: deleteDummyFriendInput!): deleteDummyFriendPayload + + "Creates a DummyFriend." + createDummyFriend(input: createDummyFriendInput!): createDummyFriendPayload + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + input updateDummyFriendInput { + id: ID! + + "The dummy name" + name: String + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + type updateDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Deletes a DummyFriend." + input deleteDummyFriendInput { + id: ID! + clientMutationId: String + } + + "Deletes a DummyFriend." + type deleteDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Creates a DummyFriend." + input createDummyFriendInput { + "The dummy name" + name: String! + clientMutationId: String + } + + "Creates a DummyFriend." + type createDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a OptionalRequiredDummy." + input updateOptionalRequiredDummyInput { + id: ID! + thirdLevel: updateThirdLevelNestedInput + thirdLevelRequired: updateThirdLevelNestedInput! + + "Get relatedToDummyFriend." + relatedToDummyFriend: [updateRelatedToDummyFriendNestedInput] + clientMutationId: String + } + SDL, $output); + } +} diff --git a/tests/Functional/GraphQl/SubscriptionTest.php b/tests/Functional/GraphQl/SubscriptionTest.php new file mode 100644 index 00000000000..72a1f43920f --- /dev/null +++ b/tests/Functional/GraphQl/SubscriptionTest.php @@ -0,0 +1,254 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Mercure\TestHub; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SubscriptionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyMercure::class, RelatedDummy::class]; + } + + public function testIntrospectSubscriptionType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Subscription") { + fields { + name + description + type { name kind } + args { + name + type { name kind ofType { name kind } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $fields = $response->toArray()['data']['__type']['fields']; + $this->assertNotEmpty($fields); + + foreach ($fields as $field) { + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+Subscribe$/', $field['name']); + $this->assertMatchesRegularExpression('/^Subscribes to the update event of a [A-Za-z0-9_]+\.$/', $field['description']); + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+SubscriptionPayload$/', $field['type']['name']); + $this->assertSame('OBJECT', $field['type']['kind']); + + $this->assertCount(1, $field['args']); + $arg = $field['args'][0]; + $this->assertSame('input', $arg['name']); + $this->assertSame('NON_NULL', $arg['type']['kind']); + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+SubscriptionInput$/', $arg['type']['ofType']['name']); + $this->assertSame('INPUT_OBJECT', $arg['type']['ofType']['kind']); + } + } + + public function testSubscribeToUpdatesProducesMercureUrl(): void + { + $this->recreateSchema($this->resources()); + $this->seedDummyMercure(2); + + $response = $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { + dummyMercure { + id + name + relatedDummy { + name + } + } + mercureUrl + clientSubscriptionId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['updateDummyMercureSubscribe']; + $this->assertSame('/dummy_mercures/1', $data['dummyMercure']['id']); + $this->assertSame('Dummy Mercure #1', $data['dummyMercure']['name']); + $this->assertSame('myId', $data['clientSubscriptionId']); + $this->assertMatchesRegularExpression( + '@^https://demo\.mercure\.rocks\?topic=http://[^/]+/subscriptions/[a-f0-9]+$@', + $data['mercureUrl'], + ); + + $response = $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { + dummyMercure { id } + mercureUrl + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['updateDummyMercureSubscribe']; + $this->assertSame('/dummy_mercures/2', $data['dummyMercure']['id']); + $this->assertMatchesRegularExpression( + '@^https://demo\.mercure\.rocks\?topic=http://[^/]+/subscriptions/[a-f0-9]+$@', + $data['mercureUrl'], + ); + } + + public function testReceiveMercureUpdatesAfterPut(): void + { + $this->recreateSchema($this->resources()); + $this->seedDummyMercure(2); + + $client = self::createClient(); + $client->getKernelBrowser()->disableReboot(); + + // Subscribe to both dummies so the SubscriptionManager registers different payload shapes. + $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { + dummyMercure { id name relatedDummy { name } } + mercureUrl + } + } + QUERY); + $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { + dummyMercure { id } + mercureUrl + } + } + QUERY); + + $client->request('PUT', '/dummy_mercures/1', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Dummy Mercure #1 updated'], + ]); + $this->assertResponseIsSuccessful(); + + $client->request('PUT', '/dummy_mercures/2', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Dummy Mercure #2 updated'], + ]); + $this->assertResponseIsSuccessful(); + + /** @var TestHub $hub */ + $hub = static::getContainer()->get('mercure.hub.default.test_hub'); + $updates = $hub->getUpdates(); + + $this->assertGreaterThanOrEqual(2, \count($updates)); + + $this->assertMercureUpdatePresent($updates, '#^http://[^/]+/subscriptions/[a-f0-9]+$#', [ + 'dummyMercure' => [ + 'id' => 1, + 'name' => 'Dummy Mercure #1 updated', + 'relatedDummy' => ['name' => 'RelatedDummy #1'], + ], + ]); + + $this->assertMercureUpdatePresent($updates, '#^http://[^/]+/subscriptions/[a-f0-9]+$#', [ + 'dummyMercure' => ['id' => 2], + ]); + } + + /** + * @param list<\Symfony\Component\Mercure\Update> $updates + * @param array $expectedPayload + */ + private function assertMercureUpdatePresent(array $updates, string $topicPattern, array $expectedPayload): void + { + $expectedJson = json_encode($expectedPayload, \JSON_THROW_ON_ERROR); + + foreach ($updates as $update) { + $topicsMatch = false; + foreach ($update->getTopics() as $topic) { + if (preg_match($topicPattern, (string) $topic)) { + $topicsMatch = true; + break; + } + } + if (!$topicsMatch) { + continue; + } + + if ($update->getData() === $expectedJson) { + $this->assertTrue(true); + + return; + } + } + + $this->fail(\sprintf( + 'No Mercure update matched topic %s with payload %s. Captured: %s', + $topicPattern, + $expectedJson, + json_encode(array_map( + static fn ($u) => ['topics' => $u->getTopics(), 'data' => $u->getData()], + $updates, + ), \JSON_PRETTY_PRINT), + )); + } + + /** + * @return list + */ + private function resources(): array + { + return [ + $this->isMongoDB() ? DummyMercureDocument::class : DummyMercure::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]; + } + + private function seedDummyMercure(int $count): void + { + $manager = $this->getManager(); + $relatedClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $dummyClass = $this->isMongoDB() ? DummyMercureDocument::class : DummyMercure::class; + + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->setName('RelatedDummy #'.$i); + + $dummy = new $dummyClass(); + $dummy->name = "Dummy Mercure #$i"; + $dummy->description = 'Description'; + $dummy->relatedDummy = $related; + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 353153b9f69..0b3571a0e9c 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -73,7 +73,7 @@ public function testShouldMapBetweenResourceAndEntity(): void $this->markTestSkipped('ObjectMapper not installed'); } - $this->recreateSchema([MappedEntity::class]); + $this->recreateSchema([$this->isMongoDB() ? MappedDocument::class : MappedEntity::class]); $this->loadFixtures(); $client = self::createClient(); $client->request('GET', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources'); diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php index d6aa24c078f..eba8ce3f10c 100644 --- a/tests/Functional/NullOnNonNullablePropertyTest.php +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -16,6 +16,9 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; use ApiPlatform\Tests\SetupClassResourcesTrait; +use Composer\InstalledVersions; +use Composer\Semver\VersionParser; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @see https://github.com/symfony/symfony/issues/64159 */ final class NullOnNonNullablePropertyTest extends ApiTestCase @@ -45,8 +48,13 @@ public function testNullOnNonNullablePropertyReturns400(): void $this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? ''); } + #[IgnoreDeprecations] public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void { + if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { + $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); + } + $response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => null], diff --git a/tests/Functional/SubResource/SubResourceTest.php b/tests/Functional/SubResource/SubResourceTest.php index 45d79665fae..cfc7c2328cf 100644 --- a/tests/Functional/SubResource/SubResourceTest.php +++ b/tests/Functional/SubResource/SubResourceTest.php @@ -369,7 +369,7 @@ public function testGetOffersFromAggregateOffers(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/DummyOffer', + '@context' => '/contexts/DummyOfferByProductOffer', '@id' => '/dummy_products/2/offers/1/offers', '@type' => 'hydra:Collection', 'hydra:member' => [[ @@ -391,7 +391,7 @@ public function testGetOffersFromAggregateOffersDirect(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/DummyOffer', + '@context' => '/contexts/DummyOfferByAggregate', '@id' => '/dummy_aggregate_offers/1/offers', '@type' => 'hydra:Collection', 'hydra:member' => [[ @@ -448,7 +448,7 @@ public function testPersonSentGreetings(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/Greeting', + '@context' => '/contexts/GreetingBySender', '@id' => '/people/1/sent_greetings', '@type' => 'hydra:Collection', 'hydra:member' => [[ diff --git a/tests/Functional/SubResource/SubResourceWithoutGetTest.php b/tests/Functional/SubResource/SubResourceWithoutGetTest.php new file mode 100644 index 00000000000..cc8e42880f4 --- /dev/null +++ b/tests/Functional/SubResource/SubResourceWithoutGetTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\SubResource; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\Event; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\ItemLog; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Ramsey\Uuid\Uuid; + +final class SubResourceWithoutGetTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Event::class, ItemLog::class]; + } + + public function testGetSubresourceFromInverseSideWithoutItemOperation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema([Event::class, ItemLog::class]); + + $manager = $this->getManager(); + $event = new Event(); + $event->logs = new ArrayCollection([new ItemLog(), new ItemLog()]); + $event->uuid = Uuid::fromString('03af3507-271e-4cca-8eee-6244fb06e95b'); + $manager->persist($event); + foreach ($event->logs as $log) { + $log->item = $event; + $manager->persist($log); + } + $manager->flush(); + + self::createClient()->request('GET', '/events/03af3507-271e-4cca-8eee-6244fb06e95b/logs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/RecreateSchemaTrait.php b/tests/RecreateSchemaTrait.php index a5f53cb9326..24511eec159 100644 --- a/tests/RecreateSchemaTrait.php +++ b/tests/RecreateSchemaTrait.php @@ -31,7 +31,7 @@ private function recreateSchema(array $classes = []): void $schemaManager = $manager->getSchemaManager(); $firstDocumentClass = null; foreach ($classes as $c) { - $class = str_contains($c, 'Entity') ? str_replace('Entity', 'Document', $c) : $c; + $class = str_contains($c, '\\Entity\\') ? str_replace('\\Entity\\', '\\Document\\', $c) : $c; $firstDocumentClass ??= $class; $schemaManager->dropDocumentCollection($class); } diff --git a/tests/SetupClassResourcesTrait.php b/tests/SetupClassResourcesTrait.php index 32f08efc2f9..16b61907db7 100644 --- a/tests/SetupClassResourcesTrait.php +++ b/tests/SetupClassResourcesTrait.php @@ -26,6 +26,7 @@ public static function setUpBeforeClass(): void public static function tearDownAfterClass(): void { + static::ensureKernelShutdown(); static::removeResources(); $reflectionClass = new \ReflectionClass(Router::class); $reflectionClass->setStaticPropertyValue('cache', []); diff --git a/tests/TestSuiteConfigCache.php b/tests/TestSuiteConfigCache.php index e26db511af0..94a97673507 100644 --- a/tests/TestSuiteConfigCache.php +++ b/tests/TestSuiteConfigCache.php @@ -48,6 +48,8 @@ public function write(string $content, ?array $metadata = null): void private function getHash(): string { - return hash_file('xxh3', __DIR__.'/Fixtures/app/var/resources.php'); + $file = __DIR__.'/Fixtures/app/var/resources.php'; + + return is_file($file) ? hash_file('xxh3', $file) : ''; } } diff --git a/tests/WithResourcesTrait.php b/tests/WithResourcesTrait.php index 464c653c263..aa8ee610b49 100644 --- a/tests/WithResourcesTrait.php +++ b/tests/WithResourcesTrait.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; + trait WithResourcesTrait { /** @@ -21,10 +23,53 @@ trait WithResourcesTrait protected static function writeResources(array $resources): void { file_put_contents(__DIR__.'/Fixtures/app/var/resources.php', \sprintf(' $v.'::class', $resources)))); + self::invalidateMetadataPools(); } protected static function removeResources(): void { file_put_contents(__DIR__.'/Fixtures/app/var/resources.php', 'hasProperty('valuesCache')) { + $property = $reflection->getProperty('valuesCache'); + $property->setValue(null, []); + } + } + + private static function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + + @rmdir($dir); } } From 878c8d714f56e3835d75c6b43bd50f7835e71fd1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 15:20:29 +0200 Subject: [PATCH 7/7] fix(jsonapi): drop consumed pagination keys before raw-param replace 37e361339 reinjected the raw bracket-style page array after JsonApiProvider had already hoisted its contents into _api_filters, so _api_filters['page'] surfaced as an array (e.g. ['itemsPerPage' => 15] for ?page[itemsPerPage]=15) and downstream providers expecting a scalar threw "Page must be a positive integer". Strip filter/page/itemsPerPage/pagination/partial/include/fields from $rawParams before array_replace; those were already processed above. Refs #8216 --- src/JsonApi/State/JsonApiProvider.php | 4 +++- .../Tests/State/JsonApiProviderTest.php | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/JsonApi/State/JsonApiProvider.php b/src/JsonApi/State/JsonApiProvider.php index c09c2fdb686..9a341fe90a8 100644 --- a/src/JsonApi/State/JsonApiProvider.php +++ b/src/JsonApi/State/JsonApiProvider.php @@ -96,7 +96,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $rawParams = $queryString ? RequestParser::parseRequestParams($queryString) : []; $request->attributes->set('_api_query_parameters', $rawParams); } - $filters = array_replace($rawParams, $filters); + // Drop keys already consumed above so the raw bracket variants don't override the hoisted values. + $consumed = array_diff_key($rawParams, array_flip(['filter', 'page', 'itemsPerPage', 'pagination', 'partial', 'include', 'fields'])); + $filters = array_replace($consumed, $filters); $request->attributes->set('_api_filters', $filters); } diff --git a/src/JsonApi/Tests/State/JsonApiProviderTest.php b/src/JsonApi/Tests/State/JsonApiProviderTest.php index e315e8dbb2c..c5276156a46 100644 --- a/src/JsonApi/Tests/State/JsonApiProviderTest.php +++ b/src/JsonApi/Tests/State/JsonApiProviderTest.php @@ -98,4 +98,23 @@ public function testProvideHonoursPrePopulatedApiQueryParameters(): void $this->assertSame('yes', $filters['custom_override'] ?? null); $this->assertSame('1', $filters['page'] ?? null); } + + public function testProvideDoesNotReinjectBracketPageAfterHoisting(): void + { + $request = Request::create('/dummies?page[itemsPerPage]=15'); + $request->setRequestFormat('jsonapi'); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertSame('15', $filters['itemsPerPage'] ?? null); + $this->assertArrayNotHasKey('page', $filters); + } }