function ResourceTestBase::doTestPatchIndividual

Same name in other branches
  1. 11.x core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php \Drupal\Tests\jsonapi\Functional\ResourceTestBase::doTestPatchIndividual()

Tests PATCHing an individual resource, plus edge cases to ensure good DX.

2 calls to ResourceTestBase::doTestPatchIndividual()
FileTest::testIndividual in core/modules/jsonapi/tests/src/Functional/FileTest.php
Tests POST/PATCH/DELETE for an individual resource.
ResourceTestBase::testIndividual in core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
Tests POST/PATCH/DELETE for an individual resource.
2 methods override ResourceTestBase::doTestPatchIndividual()
CommentTest::doTestPatchIndividual in core/modules/jsonapi/tests/src/Functional/CommentTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
MessageTest::doTestPatchIndividual in core/modules/jsonapi/tests/src/Functional/MessageTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.

File

core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php, line 2198

Class

ResourceTestBase
Subclass this for every JSON:API resource type.

Namespace

Drupal\Tests\jsonapi\Functional

Code

protected function doTestPatchIndividual() : void {
    // @todo Remove this in https://www.drupal.org/node/2300677.
    if ($this->entity instanceof ConfigEntityInterface) {
        $this->markTestSkipped('PATCHing config entities is not yet supported.');
    }
    $prior_revision_id = (int) $this->entityLoadUnchanged($this->entity
        ->id())
        ->getRevisionId();
    // Patch testing requires that another entity of the same type exists.
    $this->anotherEntity = $this->createAnotherEntity('dupe');
    // Try with all of the following request bodies.
    $not_parseable_request_body = '!{>}<';
    $parseable_valid_request_body = Json::encode($this->getPatchDocument());
    if ($this->entity
        ->getEntityType()
        ->hasKey('label')) {
        $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'label'));
    }
    $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep([
        'data' => [
            'attributes' => [
                'field_rest_test' => $this->randomString(),
            ],
        ],
    ], $this->getPatchDocument()));
    // The 'field_rest_test' field does not allow 'view' access, so does not end
    // up in the JSON:API document. Even when we explicitly add it to the JSON
    // API document that we send in a PATCH request, it is considered invalid.
    $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep([
        'data' => [
            'attributes' => [
                'field_rest_test' => $this->entity
                    ->get('field_rest_test')
                    ->getValue(),
            ],
        ],
    ], $this->getPatchDocument()));
    $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep([
        'data' => [
            'attributes' => [
                'field_nonexistent' => $this->randomString(),
            ],
        ],
    ], $this->getPatchDocument()));
    // It is invalid to PATCH a relationship field under the attributes member.
    if ($this->entity instanceof FieldableEntityInterface && $this->entity
        ->hasField('field_jsonapi_test_entity_ref')) {
        $parseable_invalid_request_body_5 = Json::encode(NestedArray::mergeDeep([
            'data' => [
                'attributes' => [
                    'field_jsonapi_test_entity_ref' => [
                        'target_id' => $this->randomString(),
                    ],
                ],
            ],
        ], $this->getPostDocument()));
    }
    // Invalid PATCH request with missing id key.
    $parseable_invalid_request_body_6 = $this->getPatchDocument();
    unset($parseable_invalid_request_body_6['data']['id']);
    $parseable_invalid_request_body_6 = Json::encode($parseable_invalid_request_body_6);
    // The URL and Guzzle request options that will be used in this test. The
    // request options will be modified/expanded throughout this test:
    // - to first test all mistakes a developer might make, and assert that the
    //   error responses provide a good DX
    // - to eventually result in a well-formed request that succeeds.
    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/drupal/issues/2878463.
    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [
        'entity' => $this->entity
            ->uuid(),
    ]);
    // $url = $this->entity->toUrl('jsonapi');
    $request_options = [];
    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
    // DX: 405 when read-only mode is enabled.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()
        ->toString(TRUE)
        ->getGeneratedUrl()), $url, $response);
    $this->assertSame([
        'GET',
    ], $response->getHeader('Allow'));
    $this->config('jsonapi.settings')
        ->set('read_only', FALSE)
        ->save(TRUE);
    // DX: 415 when no Content-Type request header.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertSame(415, $response->getStatusCode());
    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
    // DX: 403 when unauthorized.
    $response = $this->request('PATCH', $url, $request_options);
    $reason = $this->getExpectedUnauthorizedAccessMessage('PATCH');
    $this->assertResourceErrorResponse(403, (string) $reason, $url, $response);
    $this->setUpAuthorization('PATCH');
    // DX: 400 when no request body.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
    $request_options[RequestOptions::BODY] = $not_parseable_request_body;
    // DX: 400 when un-parseable request body.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE);
    // DX: 422 when invalid entity: multiple values sent for single-value field.
    if ($this->entity
        ->getEntityType()
        ->hasKey('label')) {
        $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
        $response = $this->request('PATCH', $url, $request_options);
        $label_field = $this->entity
            ->getEntityType()
            ->getKey('label');
        $label_field_capitalized = $this->entity
            ->getFieldDefinition($label_field)
            ->getLabel();
        $this->assertResourceErrorResponse(422, "{$label_field}: {$label_field_capitalized}: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field);
    }
    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
    // DX: 403 when entity contains field without 'edit' access.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
    // DX: 403 when entity trying to update an entity's ID field.
    $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'id'));
    $response = $this->request('PATCH', $url, $request_options);
    $id_field_name = $this->entity
        ->getEntityType()
        ->getKey('id');
    $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field ({$id_field_name}). The entity ID cannot be changed.", $url, $response, "/data/attributes/{$id_field_name}");
    if ($this->entity
        ->getEntityType()
        ->hasKey('uuid')) {
        // DX: 400 when entity trying to update an entity's UUID field.
        $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'uuid'));
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceErrorResponse(400, sprintf("The selected entity (%s) does not match the ID in the payload (%s).", $this->entity
            ->uuid(), $this->anotherEntity
            ->uuid()), $url, $response, FALSE);
    }
    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
    // DX: 403 when entity contains field without 'edit' nor 'view' access, even
    // when the value for that field matches the current value. This is allowed
    // in principle, but leads to information disclosure.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
    // DX: 403 when sending PATCH request with updated read-only fields.
    [
        $modified_entity,
        $original_values,
    ] = static::getModifiedEntityForPatchTesting($this->entity);
    // Send PATCH request by serializing the modified entity, assert the error
    // response, change the modified entity field that caused the error response
    // back to its original value, repeat.
    foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) {
        $request_options[RequestOptions::BODY] = Json::encode($this->normalize($modified_entity, $url));
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''), $url->setAbsolute(), $response, '/data/attributes/' . $patch_protected_field_name);
        $modified_entity->get($patch_protected_field_name)
            ->setValue($original_values[$patch_protected_field_name]);
    }
    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4;
    // DX: 422 when request document contains non-existent field.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE);
    // DX: 422 when updating a relationship field under attributes.
    if (isset($parseable_invalid_request_body_5)) {
        $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_5;
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceErrorResponse(422, "The following relationship fields were provided as attributes: [ field_jsonapi_test_entity_ref ]", $url, $response, FALSE);
    }
    // DX: 400 when request document doesn't contain id.
    // This also tests that no PHP warnings raised due to non-existent key.
    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_6;
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceResponse(400, FALSE, $response);
    // 200 for well-formed PATCH request that sends all fields (even including
    // read-only ones, but with unchanged values).
    $valid_request_body = NestedArray::mergeDeep($this->normalize($this->entity, $url), $this->getPatchDocument());
    $request_options[RequestOptions::BODY] = Json::encode($valid_request_body);
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceResponse(200, FALSE, $response);
    $updated_entity = $this->entityLoadUnchanged($this->entity
        ->id());
    $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
    $prior_revision_id = (int) $updated_entity->getRevisionId();
    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
    // DX: 415 when request body in existing but not allowed format.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertSame(415, $response->getStatusCode());
    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
    // 200 for well-formed request.
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceResponse(200, FALSE, $response);
    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
    // Assert that the entity was indeed updated, and that the response body
    // contains the serialized updated entity.
    $updated_entity = $this->entityLoadUnchanged($this->entity
        ->id());
    $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
    if ($this->entity instanceof RevisionLogInterface) {
        if (static::$newRevisionsShouldBeAutomatic) {
            $this->assertNotSame((int) $this->entity
                ->getRevisionCreationTime(), (int) $updated_entity->getRevisionCreationTime());
        }
        else {
            $this->assertSame((int) $this->entity
                ->getRevisionCreationTime(), (int) $updated_entity->getRevisionCreationTime());
        }
    }
    $updated_entity_document = $this->normalize($updated_entity, $url);
    $document = $this->getDocumentFromResponse($response);
    $this->assertSame($updated_entity_document, $document);
    $prior_revision_id = (int) $updated_entity->getRevisionId();
    // Assert that the entity was indeed created using the PATCHed values.
    foreach ($this->getPatchDocument()['data']['attributes'] as $field_name => $field_normalization) {
        // If the value is an array of properties, only verify that the sent
        // properties are present, the server could be computing additional
        // properties.
        if (is_array($field_normalization)) {
            foreach ($field_normalization as $value) {
                $this->assertContains($value, $updated_entity_document['data']['attributes'][$field_name]);
            }
        }
        else {
            $this->assertSame($field_normalization, $updated_entity_document['data']['attributes'][$field_name]);
        }
    }
    if (isset($this->getPatchDocument()['data']['relationships'])) {
        foreach ($this->getPatchDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) {
            // POSTing relationships: 'data' is required, 'links' is optional.
            static::recursiveKsort($relationship_field_normalization);
            static::recursiveKsort($updated_entity_document['data']['relationships'][$field_name]);
            $this->assertSame($relationship_field_normalization, array_diff_key($updated_entity_document['data']['relationships'][$field_name], [
                'links' => TRUE,
            ]));
        }
    }
    // Ensure that fields do not get deleted if they're not present in the PATCH
    // request. Test this using the configurable field that we added, but which
    // is not sent in the PATCH request.
    $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $updated_entity->get('field_rest_test')->value);
    // Multi-value field: remove item 0. Then item 1 becomes item 0.
    $doc_multi_value_tests = $this->getPatchDocument();
    $doc_multi_value_tests['data']['attributes']['field_rest_test_multivalue'] = $this->entity
        ->get('field_rest_test_multivalue')
        ->getValue();
    $doc_remove_item = $doc_multi_value_tests;
    unset($doc_remove_item['data']['attributes']['field_rest_test_multivalue'][0]);
    $request_options[RequestOptions::BODY] = Json::encode($doc_remove_item);
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceResponse(200, FALSE, $response);
    $updated_entity = $this->entityLoadUnchanged($this->entity
        ->id());
    $this->assertSame([
        0 => [
            'value' => 'Two',
        ],
    ], $updated_entity->get('field_rest_test_multivalue')
        ->getValue());
    $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
    $prior_revision_id = (int) $updated_entity->getRevisionId();
    // Multi-value field: add one item before the existing one, and one after.
    $doc_add_items = $doc_multi_value_tests;
    $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = [
        'value' => 'Three',
    ];
    $request_options[RequestOptions::BODY] = Json::encode($doc_add_items);
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceResponse(200, FALSE, $response);
    $expected_document = [
        0 => [
            'value' => 'One',
        ],
        1 => [
            'value' => 'Two',
        ],
        2 => [
            'value' => 'Three',
        ],
    ];
    $updated_entity = $this->entityLoadUnchanged($this->entity
        ->id());
    $this->assertSame($expected_document, $updated_entity->get('field_rest_test_multivalue')
        ->getValue());
    $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
    $prior_revision_id = (int) $updated_entity->getRevisionId();
    // Finally, assert that when Content Moderation is installed, a new revision
    // is automatically created when PATCHing for entity types that have a
    // moderation handler.
    // @see \Drupal\content_moderation\Entity\Handler\ModerationHandler::onPresave()
    // @see \Drupal\content_moderation\EntityTypeInfo::$moderationHandlers
    if ($updated_entity instanceof EntityPublishedInterface) {
        $updated_entity->setPublished()
            ->save();
    }
    $this->assertTrue($this->container
        ->get('module_installer')
        ->install([
        'content_moderation',
    ], TRUE), 'Installed modules.');
    if (!\Drupal::service('content_moderation.moderation_information')->canModerateEntitiesOfEntityType($this->entity
        ->getEntityType())) {
        return;
    }
    $workflow = $this->createEditorialWorkflow();
    $workflow->getTypePlugin()
        ->addEntityTypeAndBundle(static::$entityTypeId, $this->entity
        ->bundle());
    $workflow->save();
    $this->grantPermissionsToTestedRole([
        'use editorial transition publish',
    ]);
    $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = [
        'value' => '3',
    ];
    $request_options[RequestOptions::BODY] = Json::encode($doc_add_items);
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceResponse(200, FALSE, $response);
    $expected_document = [
        0 => [
            'value' => 'One',
        ],
        1 => [
            'value' => 'Two',
        ],
        2 => [
            'value' => '3',
        ],
    ];
    $updated_entity = $this->entityLoadUnchanged($this->entity
        ->id());
    $this->assertSame($expected_document, $updated_entity->get('field_rest_test_multivalue')
        ->getValue());
    if ($this->entity
        ->getEntityType()
        ->hasHandlerClass('moderation')) {
        $this->assertLessThan((int) $updated_entity->getRevisionId(), $prior_revision_id);
    }
    else {
        $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
    }
    // Ensure that PATCHing an entity that is not the latest revision is
    // unsupported.
    if (!$this->entity
        ->getEntityType()
        ->isRevisionable() || !$this->entity
        ->getEntityType()
        ->hasHandlerClass('moderation') || !$this->entity instanceof FieldableEntityInterface) {
        return;
    }
    assert($this->entity instanceof RevisionableInterface);
    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
    $request_options[RequestOptions::BODY] = Json::encode([
        'data' => [
            'type' => static::$resourceTypeName,
            'id' => $this->entity
                ->uuid(),
        ],
    ]);
    $this->setUpAuthorization('PATCH');
    $this->grantPermissionsToTestedRole([
        'use editorial transition create_new_draft',
        'use editorial transition archived_published',
        'use editorial transition publish',
    ]);
    // Disallow PATCHing an entity that has a pending revision.
    $updated_entity->set('moderation_state', 'draft');
    $updated_entity->setNewRevision();
    $updated_entity->save();
    $actual_response = $this->request('PATCH', $url, $request_options);
    $this->assertResourceErrorResponse(400, 'Updating a resource object that has a working copy is not yet supported. See https://www.drupal.org/project/drupal/issues/2795279.', $url, $actual_response);
    // Allow PATCHing an unpublished default revision.
    $updated_entity->set('moderation_state', 'archived');
    $updated_entity->setNewRevision();
    $updated_entity->save();
    $actual_response = $this->request('PATCH', $url, $request_options);
    $this->assertSame(200, $actual_response->getStatusCode());
    // Allow PATCHing an unpublished default revision. (An entity that
    // transitions from archived to draft remains an unpublished default
    // revision.)
    $updated_entity->set('moderation_state', 'draft');
    $updated_entity->setNewRevision();
    $updated_entity->save();
    $actual_response = $this->request('PATCH', $url, $request_options);
    $this->assertSame(200, $actual_response->getStatusCode());
    // Allow PATCHing a published default revision.
    $updated_entity->set('moderation_state', 'published');
    $updated_entity->setNewRevision();
    $updated_entity->save();
    $actual_response = $this->request('PATCH', $url, $request_options);
    $this->assertSame(200, $actual_response->getStatusCode());
}

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.