You are here

public function ResourceTestBase::testPatchIndividual in Drupal 9

Same name and namespace in other branches
  1. 8 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.

1 call to ResourceTestBase::testPatchIndividual()
CommentTest::testPatchIndividual in core/modules/jsonapi/tests/src/Functional/CommentTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
4 methods override ResourceTestBase::testPatchIndividual()
CommentTest::testPatchIndividual in core/modules/jsonapi/tests/src/Functional/CommentTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
FileUploadTest::testPatchIndividual in core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
@requires module irrelevant_for_this_test
ItemTest::testPatchIndividual in core/modules/jsonapi/tests/src/Functional/ItemTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
MessageTest::testPatchIndividual 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 2169

Class

ResourceTestBase
Subclass this for every JSON:API resource type.

Namespace

Drupal\Tests\jsonapi\Functional

Code

public function testPatchIndividual() {

  // @todo Remove this in https://www.drupal.org/node/2300677.
  if ($this->entity instanceof ConfigEntityInterface) {
    $this
      ->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
    return;
  }
  $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.
  list($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());
}