You are here

public function ResourceTestBase::testPatchIndividual in JSON:API 8

Same name and namespace in other branches
  1. 8.2 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()
BlockContentTest::testPatchIndividual in tests/src/Functional/BlockContentTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
4 methods override ResourceTestBase::testPatchIndividual()
BlockContentTest::testPatchIndividual in tests/src/Functional/BlockContentTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
ItemTest::testPatchIndividual in tests/src/Functional/ItemTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
MessageTest::testPatchIndividual in tests/src/Functional/MessageTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.
ShortcutTest::testPatchIndividual in tests/src/Functional/ShortcutTest.php
Tests PATCHing an individual resource, plus edge cases to ensure good DX.

File

tests/src/Functional/ResourceTestBase.php, line 2021

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;
  }

  // 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());

  /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPatchEntity()); */
  $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()));

  // 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/jsonapi/issues/2878463.
  $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [
    static::$entityTypeId => $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());

  // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2934149.
  // @codingStandardsIgnoreStart

  /*
      // DX: 415 when no Content-Type request header.
      $response = $this->request('PATCH', $url, $request_options);
      $this->assertSame(415, $response->getStatusCode());
      $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
      $this->assertContains('A client error happened', (string) $response->getBody());

      $url->setOption('query', ['_format' => static::$format]);

      // DX: 415 when no Content-Type request header.
      $response = $this->request('PATCH', $url, $request_options);
      $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);

      $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
  */

  // @codingStandardsIgnoreEnd
  // DX: 400 when no request body.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(400, 'Empty request body.', $response);
  $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', $response);
  $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;

  // DX: 403 when unauthorized.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $reason = $this
    ->getExpectedUnauthorizedAccessMessage('PATCH');

  // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
  $expected_document = [
    'errors' => [
      [
        'title' => 'Forbidden',
        'status' => 403,
        'detail' => "The current user is not allowed to PATCH the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
        'links' => [
          'info' => HttpExceptionNormalizer::getInfoUrl(403),
        ],
        'code' => 0,
        'id' => '/' . static::$resourceTypeName . '/' . $this->entity
          ->uuid(),
        'source' => [
          'pointer' => '/data',
        ],
      ],
    ],
  ];
  $this
    ->assertResourceResponse(403, $expected_document, $response);

  /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected resource." . (strlen($reason) ? ' ' . $reason : ''), $response, '/data'); */
  $this
    ->setUpAuthorization('PATCH');

  // DX: 422 when invalid entity: multiple values sent for single-value field.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $label_field = $this->entity
    ->getEntityType()
    ->hasKey('label') ? $this->entity
    ->getEntityType()
    ->getKey('label') : static::$labelFieldName;
  $label_field_capitalized = $this->entity
    ->getFieldDefinition($label_field)
    ->getLabel();

  // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
  $expected_document = [
    'errors' => [
      [
        'title' => 'Unprocessable Entity',
        'status' => 422,
        'detail' => "{$label_field}: {$label_field_capitalized}: this field cannot hold more than 1 values.",
        'code' => 0,
        'source' => [
          'pointer' => '/data/attributes/' . $label_field,
        ],
      ],
    ],
  ];
  $this
    ->assertResourceResponse(422, $expected_document, $response);

  /* $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", $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);

  // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
  $expected_document = [
    'errors' => [
      [
        'title' => 'Forbidden',
        'status' => 403,
        'detail' => "The current user is not allowed to PATCH the selected field (field_rest_test).",
        'links' => [
          'info' => HttpExceptionNormalizer::getInfoUrl(403),
        ],
        'code' => 0,
        'id' => '/' . static::$resourceTypeName . '/' . $this->entity
          ->uuid(),
        'source' => [
          'pointer' => '/data/attributes/field_rest_test',
        ],
      ],
    ],
  ];
  $this
    ->assertResourceResponse(403, $expected_document, $response);

  /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $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');

  // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
  $expected_document = [
    'errors' => [
      [
        'title' => 'Forbidden',
        'status' => 403,
        'detail' => "The current user is not allowed to PATCH the selected field ({$id_field_name}). The entity ID cannot be changed.",
        'links' => [
          'info' => HttpExceptionNormalizer::getInfoUrl(403),
        ],
        'code' => 0,
        'id' => '/' . static::$resourceTypeName . '/' . $this->entity
          ->uuid(),
        'source' => [
          'pointer' => '/data/attributes/' . $id_field_name,
        ],
      ],
    ],
  ];
  if (floatval(\Drupal::VERSION) < 8.6) {
    $expected_document['errors'][0]['detail'] = "The current user is not allowed to PATCH the selected field ({$id_field_name}). The entity ID cannot be changed";
  }
  $this
    ->assertResourceResponse(403, $expected_document, $response);

  /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field ($id_field_name). The entity ID cannot be changed", $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()), $response);
  }
  $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);

  // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
  $expected_document = [
    'errors' => [
      [
        'title' => 'Forbidden',
        'status' => 403,
        'detail' => "The current user is not allowed to PATCH the selected field (field_rest_test).",
        'links' => [
          'info' => HttpExceptionNormalizer::getInfoUrl(403),
        ],
        'code' => 0,
        'id' => '/' . static::$resourceTypeName . '/' . $this->entity
          ->uuid(),
        'source' => [
          'pointer' => '/data/attributes/field_rest_test',
        ],
      ],
    ],
  ];
  $this
    ->assertResourceResponse(403, $expected_document, $response);

  /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $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);

    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
    $expected_document = [
      'errors' => [
        [
          'title' => 'Forbidden',
          'status' => 403,
          'detail' => "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''),
          'links' => [
            'info' => HttpExceptionNormalizer::getInfoUrl(403),
          ],
          'code' => 0,
          'id' => '/' . static::$resourceTypeName . '/' . $this->entity
            ->uuid(),
          'source' => [
            'pointer' => '/data/attributes/' . $patch_protected_field_name,
          ],
        ],
      ],
    ];
    $this
      ->assertResourceResponse(403, $expected_document, $response);

    /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''), $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), $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);
  $request_options[RequestOptions::BODY] = $parseable_valid_request_body;

  // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934149 lands.
  // @codingStandardsIgnoreStart

  /*
  $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->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
  */

  // @codingStandardsIgnoreEnd
  $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->entityStorage
    ->loadUnchanged($this->entity
    ->id());
  $updated_entity_document = $this
    ->normalize($updated_entity, $url);
  $this
    ->assertSame($updated_entity_document, Json::decode((string) $response
    ->getBody()));

  // Assert that the entity was indeed created using the PATCHed values.
  foreach ($this
    ->getPatchDocument() as $field_name => $field_normalization) {

    // Some top-level keys in the normalization may not be fields on the
    // entity (for example '_links' and '_embedded' in the HAL normalization).
    if ($updated_entity
      ->hasField($field_name)) {

      // Subset, not same, because we can e.g. send just the target_id for the
      // bundle in a PATCH request; the response will include more properties.
      $this
        ->assertArraySubset($field_normalization, $updated_entity
        ->get($field_name)
        ->getValue(), 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);

  // @todo Remove this when JSON API requires Drupal 8.5 or newer.
  if (floatval(\Drupal::VERSION) < 8.5) {
    return;
  }

  // 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);
  $this
    ->assertSame([
    0 => [
      'value' => 'Two',
    ],
  ], $this->entityStorage
    ->loadUnchanged($this->entity
    ->id())
    ->get('field_rest_test_multivalue')
    ->getValue());

  // 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',
    ],
  ];
  $this
    ->assertSame($expected_document, $this->entityStorage
    ->loadUnchanged($this->entity
    ->id())
    ->get('field_rest_test_multivalue')
    ->getValue());
}