public function ResourceTestBase::testPatchIndividual in Drupal 10
Same name and namespace in other branches
- 8 core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php \Drupal\Tests\jsonapi\Functional\ResourceTestBase::testPatchIndividual()
- 9 core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php \Drupal\Tests\jsonapi\Functional\ResourceTestBase::testPatchIndividual()
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
File
- core/
modules/ jsonapi/ tests/ src/ Functional/ ResourceTestBase.php, line 2178
Class
- ResourceTestBase
- Subclass this for every JSON:API resource type.
Namespace
Drupal\Tests\jsonapi\FunctionalCode
public function testPatchIndividual() {
// @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.
$unparseable_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()));
}
// 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] = $unparseable_request_body;
// DX: 400 when unparseable 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);
}
// 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);
$this
->assertSame($updated_entity_document, Json::decode((string) $response
->getBody()));
$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, 'api_json');
$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());
}