ResourceTestBase.php

Same filename in this branch
  1. 11.x core/modules/rest/tests/src/Functional/ResourceTestBase.php
Same filename in other branches
  1. 9 core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
  2. 9 core/modules/rest/tests/src/Functional/ResourceTestBase.php
  3. 8.9.x core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
  4. 8.9.x core/modules/rest/tests/src/Functional/ResourceTestBase.php
  5. 10 core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
  6. 10 core/modules/rest/tests/src/Functional/ResourceTestBase.php

Namespace

Drupal\Tests\jsonapi\Functional

File

core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Tests\jsonapi\Functional;

use Drupal\jsonapi\JsonApiSpec;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Random;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Cache\CacheRedirect;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\ContentEntityNullStorage;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\TypedData\DataReferenceTargetDefinition;
use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\jsonapi\CacheableResourceResponse;
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
use Drupal\jsonapi\JsonApiResource\Link;
use Drupal\jsonapi\JsonApiResource\LinkCollection;
use Drupal\jsonapi\JsonApiResource\NullIncludedData;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
use Drupal\jsonapi\ResourceResponse;
use Drupal\path\Plugin\Field\FieldType\PathItem;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\jsonapi\Traits\GetDocumentFromResponseTrait;
use Drupal\user\Entity\Role;
use Drupal\user\EntityOwnerInterface;
use Drupal\user\RoleInterface;
use Drupal\user\UserInterface;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\HttpFoundation\Response;

/**
 * Subclass this for every JSON:API resource type.
 */
abstract class ResourceTestBase extends BrowserTestBase {
    use ResourceResponseTestTrait;
    use ContentModerationTestTrait;
    use GetDocumentFromResponseTrait;
    use JsonApiRequestTestTrait;
    
    /**
     * {@inheritdoc}
     */
    protected static $modules = [
        'jsonapi',
        'basic_auth',
        'rest_test',
        'jsonapi_test_field_access',
        'text',
    ];
    
    /**
     * The tested entity type.
     *
     * @var string
     */
    protected static $entityTypeId = NULL;
    
    /**
     * The name of the tested JSON:API resource type.
     *
     * @var string
     */
    protected static $resourceTypeName = NULL;
    
    /**
     * Whether the tested JSON:API resource is versionable.
     *
     * @var bool
     */
    protected static $resourceTypeIsVersionable = FALSE;
    
    /**
     * The JSON:API resource type for the tested entity type plus bundle.
     *
     * Necessary for looking up public (alias) or internal (actual) field names.
     *
     * @var \Drupal\jsonapi\ResourceType\ResourceType
     */
    protected $resourceType;
    
    /**
     * The fields that are protected against modification during PATCH requests.
     *
     * @var string[]
     */
    protected static $patchProtectedFieldNames;
    
    /**
     * Fields that need unique values.
     *
     * @var string[]
     *
     * @see ::testPostIndividual()
     * @see ::getModifiedEntityForPostTesting()
     */
    protected static $uniqueFieldNames = [];
    
    /**
     * The entity ID for the first created entity in testPost().
     *
     * The default value of 2 should work for most content entities.
     *
     * @var string|int
     *
     * @see ::testPostIndividual()
     */
    protected static $firstCreatedEntityId = 2;
    
    /**
     * The entity ID for the second created entity in testPost().
     *
     * The default value of 3 should work for most content entities.
     *
     * @var string|int
     *
     * @see ::testPostIndividual()
     */
    protected static $secondCreatedEntityId = 3;
    
    /**
     * Specify which field is the 'label' field for testing a POST edge case.
     *
     * @var string|null
     *
     * @see ::testPostIndividual()
     */
    protected static $labelFieldName = NULL;
    
    /**
     * Whether new revisions of updated entities should be created by default.
     *
     * @var bool
     */
    protected static $newRevisionsShouldBeAutomatic = FALSE;
    
    /**
     * Whether anonymous users can view labels of this resource type.
     *
     * @var bool
     */
    protected static $anonymousUsersCanViewLabels = FALSE;
    
    /**
     * The standard `jsonapi` top-level document member.
     *
     * @var array
     */
    protected static $jsonApiMember = [
        'version' => JsonApiSpec::SUPPORTED_SPECIFICATION_VERSION,
        'meta' => [
            'links' => [
                'self' => [
                    'href' => JsonApiSpec::SUPPORTED_SPECIFICATION_PERMALINK,
                ],
            ],
        ],
    ];
    
    /**
     * The entity being tested.
     *
     * @var \Drupal\Core\Entity\EntityInterface
     */
    protected $entity;
    
    /**
     * Another entity of the same type used for testing.
     *
     * @var \Drupal\Core\Entity\EntityInterface
     */
    protected $anotherEntity;
    
    /**
     * The account to use for authentication.
     *
     * @var null|\Drupal\Core\Session\AccountInterface
     */
    protected $account;
    
    /**
     * The entity storage.
     *
     * @var \Drupal\Core\Entity\EntityStorageInterface
     */
    protected $entityStorage;
    
    /**
     * The UUID key.
     *
     * @var string
     */
    protected $uuidKey;
    
    /**
     * The serializer service.
     *
     * @var \Symfony\Component\Serializer\Serializer
     */
    protected $serializer;
    
    /**
     * {@inheritdoc}
     */
    protected function setUp() : void {
        parent::setUp();
        $this->serializer = $this->container
            ->get('jsonapi.serializer');
        $this->config('system.logging')
            ->set('error_level', ERROR_REPORTING_HIDE)
            ->save();
        $this->revokePermissions();
        // Create an account, which tests will use. Also ensure the @current_user
        // service this account, to ensure certain access check logic in tests works
        // as expected.
        $this->account = $this->createUser();
        $this->container
            ->get('current_user')
            ->setAccount($this->account);
        // Create an entity.
        $entity_type_manager = $this->container
            ->get('entity_type.manager');
        $this->entityStorage = $entity_type_manager->getStorage(static::$entityTypeId);
        $this->uuidKey = $entity_type_manager->getDefinition(static::$entityTypeId)
            ->getKey('uuid');
        $this->entity = $this->setUpFields($this->createEntity(), $this->account);
        $this->resourceType = $this->container
            ->get('jsonapi.resource_type.repository')
            ->getByTypeName(static::$resourceTypeName);
    }
    
    /**
     * Sets up additional fields for testing.
     *
     * @param \Drupal\Core\Entity\EntityInterface $entity
     *   The primary test entity.
     * @param \Drupal\user\UserInterface $account
     *   The primary test user account.
     *
     * @return \Drupal\Core\Entity\EntityInterface
     *   The reloaded entity with the new fields attached.
     *
     * @throws \Drupal\Core\Entity\EntityStorageException
     */
    protected function setUpFields(EntityInterface $entity, UserInterface $account) : EntityInterface {
        if (!$entity instanceof FieldableEntityInterface) {
            return $entity;
        }
        $entity_bundle = $entity->bundle();
        // Add access-protected field.
        FieldStorageConfig::create([
            'entity_type' => static::$entityTypeId,
            'field_name' => 'field_rest_test',
            'type' => 'text',
        ])->setCardinality(1)
            ->save();
        FieldConfig::create([
            'entity_type' => static::$entityTypeId,
            'field_name' => 'field_rest_test',
            'bundle' => $entity_bundle,
        ])->setLabel('Test field')
            ->setTranslatable(FALSE)
            ->save();
        FieldStorageConfig::create([
            'entity_type' => static::$entityTypeId,
            'field_name' => 'field_jsonapi_test_entity_ref',
            'type' => 'entity_reference',
        ])->setSetting('target_type', 'user')
            ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED)
            ->save();
        FieldConfig::create([
            'entity_type' => static::$entityTypeId,
            'field_name' => 'field_jsonapi_test_entity_ref',
            'bundle' => $entity_bundle,
        ])->setTranslatable(FALSE)
            ->setSetting('handler', 'default')
            ->setSetting('handler_settings', [
            'target_bundles' => NULL,
        ])
            ->save();
        // Add multi-value field.
        FieldStorageConfig::create([
            'entity_type' => static::$entityTypeId,
            'field_name' => 'field_rest_test_multivalue',
            'type' => 'string',
        ])->setCardinality(3)
            ->save();
        FieldConfig::create([
            'entity_type' => static::$entityTypeId,
            'field_name' => 'field_rest_test_multivalue',
            'bundle' => $entity_bundle,
        ])->setLabel('Test field: multi-value')
            ->setTranslatable(FALSE)
            ->save();
        \Drupal::service('router.builder')->rebuildIfNeeded();
        return $this->resaveEntity($entity, $account);
    }
    protected function resaveEntity(EntityInterface $entity, AccountInterface $account) : EntityInterface {
        // Reload entity so that it has the new field.
        $reloaded_entity = $this->entityLoadUnchanged($entity->id());
        // Some entity types are not stored, hence they cannot be reloaded.
        if ($reloaded_entity !== NULL) {
            $entity = $reloaded_entity;
            // Set a default value on the fields.
            $entity->set('field_rest_test', [
                'value' => 'All the faith he had had had had no effect on the outcome of his life.',
            ]);
            $entity->set('field_jsonapi_test_entity_ref', [
                'user' => $account->id(),
            ]);
            $entity->set('field_rest_test_multivalue', [
                [
                    'value' => 'One',
                ],
                [
                    'value' => 'Two',
                ],
            ]);
            $entity->save();
        }
        return $entity;
    }
    
    /**
     * Sets up a collection of entities of the same type for testing.
     *
     * @return \Drupal\Core\Entity\EntityInterface[]
     *   The collection of entities to test.
     *
     * @throws \Drupal\Core\Entity\EntityStorageException
     */
    protected function getData() {
        if ($this->entityStorage
            ->getQuery()
            ->accessCheck(FALSE)
            ->count()
            ->execute() < 2) {
            $this->createAnotherEntity('two');
        }
        $query = $this->entityStorage
            ->getQuery()
            ->accessCheck(FALSE)
            ->sort($this->entity
            ->getEntityType()
            ->getKey('id'));
        return $this->entityStorage
            ->loadMultiple($query->execute());
    }
    
    /**
     * Generates a JSON:API normalization for the given entity.
     *
     * @param \Drupal\Core\Entity\EntityInterface $entity
     *   The entity to generate a JSON:API normalization for.
     * @param \Drupal\Core\Url $url
     *   The URL to use as the "self" link.
     *
     * @return array
     *   The JSON:API normalization for the given entity.
     */
    protected function normalize(EntityInterface $entity, Url $url) {
        // Don't use cached normalizations in tests.
        $this->container
            ->get('cache.jsonapi_normalizations')
            ->deleteAll();
        $self_link = new Link(new CacheableMetadata(), $url, 'self');
        $resource_type = $this->container
            ->get('jsonapi.resource_type.repository')
            ->getByTypeName(static::$resourceTypeName);
        $doc = new JsonApiDocumentTopLevel(new ResourceObjectData([
            ResourceObject::createFromEntity($resource_type, $entity),
        ], 1), new NullIncludedData(), new LinkCollection([
            'self' => $self_link,
        ]));
        return $this->serializer
            ->normalize($doc, 'api_json', [
            'resource_type' => $resource_type,
            'account' => $this->account,
        ])
            ->getNormalization();
    }
    
    /**
     * Creates the entity to be tested.
     *
     * @return \Drupal\Core\Entity\EntityInterface
     *   The entity to be tested.
     */
    protected abstract function createEntity();
    
    /**
     * Creates another entity to be tested.
     *
     * @param mixed $key
     *   A unique key to be used for the ID and/or label of the duplicated entity.
     *
     * @return \Drupal\Core\Entity\EntityInterface
     *   Another entity based on $this->entity.
     *
     * @throws \Drupal\Core\Entity\EntityStorageException
     */
    protected function createAnotherEntity($key) {
        $duplicate = $this->getEntityDuplicate($this->entity, $key);
        // Some entity types are not stored, hence they cannot be reloaded.
        if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
            $duplicate->set('field_rest_test', 'Second collection entity');
        }
        $duplicate->save();
        return $duplicate;
    }
    
    /**
     * {@inheritdoc}
     */
    protected function getEntityDuplicate(EntityInterface $original, $key) {
        $duplicate = $original->createDuplicate();
        if ($label_key = $original->getEntityType()
            ->getKey('label')) {
            $duplicate->set($label_key, $original->label() . '_' . $key);
        }
        $id_key = $duplicate->getEntityType()
            ->getKey('id');
        $needs_manual_id = $duplicate instanceof ConfigEntityInterface && $id_key;
        if ($duplicate instanceof FieldableEntityInterface && $id_key) {
            $id_field = $duplicate->getFieldDefinition($id_key);
            if ($id_field->getType() !== 'integer') {
                $needs_manual_id = TRUE;
            }
        }
        if ($needs_manual_id) {
            $duplicate->set($id_key, $original->id() . '_' . $key);
        }
        return $duplicate;
    }
    
    /**
     * Returns the expected JSON:API document for the entity.
     *
     * @see ::createEntity()
     *
     * @return array
     *   A JSON:API response document.
     */
    protected abstract function getExpectedDocument();
    
    /**
     * Returns the JSON:API POST document.
     *
     * @see ::testPostIndividual()
     *
     * @return array
     *   A JSON:API request document.
     */
    protected abstract function getPostDocument();
    
    /**
     * Returns the JSON:API PATCH document.
     *
     * By default, reuses ::getPostDocument(), which works fine for most entity
     * types. A counter example: the 'comment' entity type.
     *
     * @see ::testPatchIndividual()
     *
     * @return array
     *   A JSON:API request document.
     */
    protected function getPatchDocument() {
        return NestedArray::mergeDeep([
            'data' => [
                'id' => $this->entity
                    ->uuid(),
            ],
        ], $this->getPostDocument());
    }
    
    /**
     * Returns the expected cacheability for an unauthorized response.
     *
     * @return \Drupal\Core\Cache\CacheableMetadata
     *   The expected cacheability.
     */
    protected function getExpectedUnauthorizedAccessCacheability() {
        return (new CacheableMetadata())->setCacheTags([
            '4xx-response',
            'http_response',
        ])
            ->setCacheContexts([
            'url.query_args',
            'url.site',
            'user.permissions',
        ])
            ->addCacheContexts($this->entity
            ->getEntityType()
            ->isRevisionable() ? [
            'url.query_args:resourceVersion',
        ] : []);
    }
    
    /**
     * The expected cache tags for the GET/HEAD response of the test entity.
     *
     * @param array|null $sparse_fieldset
     *   If a sparse fieldset is being requested, limit the expected cache tags
     *   for this entity's fields to just these fields.
     *
     * @return string[]
     *   A set of cache tags.
     *
     * @see ::testGetIndividual()
     */
    protected function getExpectedCacheTags(?array $sparse_fieldset = NULL) {
        $expected_cache_tags = [
            'http_response',
        ];
        return Cache::mergeTags($expected_cache_tags, $this->entity
            ->getCacheTags());
    }
    
    /**
     * The expected cache tags when checking revision responses.
     *
     * @return string[]
     *   A set of cache tags.
     */
    protected function getExtraRevisionCacheTags() {
        return [];
    }
    
    /**
     * The expected cache contexts for the GET/HEAD response of the test entity.
     *
     * @param array|null $sparse_fieldset
     *   If a sparse fieldset is being requested, limit the expected cache
     *   contexts for this entity's fields to just these fields.
     *
     * @return string[]
     *   A set of cache contexts.
     *
     * @see ::testGetIndividual()
     */
    protected function getExpectedCacheContexts(?array $sparse_fieldset = NULL) {
        $cache_contexts = [
            // Cache contexts for JSON:API URL query parameters.
'url.query_args',
            // Drupal defaults.
'url.site',
            'user.permissions',
        ];
        $entity_type = $this->entity
            ->getEntityType();
        return Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? [
            'url.query_args:resourceVersion',
        ] : []);
    }
    
    /**
     * Computes the cacheability for a given entity collection.
     *
     * @param \Drupal\Core\Session\AccountInterface $account
     *   An account for which cacheability should be computed (cacheability is
     *   dependent on access).
     * @param \Drupal\Core\Entity\EntityInterface[] $collection
     *   The entities for which cacheability should be computed.
     * @param array $sparse_fieldset
     *   (optional) If a sparse fieldset is being requested, limit the expected
     *   cacheability for the collection entities' fields to just those in the
     *   fieldset. NULL means all fields.
     * @param bool $filtered
     *   Whether the collection is filtered or not.
     *
     * @return \Drupal\Core\Cache\CacheableMetadata
     *   The expected cacheability for the given entity collection.
     */
    protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, ?array $sparse_fieldset = NULL, $filtered = FALSE) {
        $cacheability = array_reduce($collection, function (CacheableMetadata $cacheability, EntityInterface $entity) use ($sparse_fieldset, $account) {
            $access_result = static::entityAccess($entity, 'view', $account);
            if (!$access_result->isAllowed()) {
                $access_result = static::entityAccess($entity, 'view label', $account)->addCacheableDependency($access_result);
            }
            $cacheability->addCacheableDependency($access_result);
            if ($access_result->isAllowed()) {
                $cacheability->addCacheableDependency($entity);
                if ($entity instanceof FieldableEntityInterface) {
                    foreach ($entity as $field_name => $field_item_list) {
                        
                        /** @var \Drupal\Core\Field\FieldItemListInterface $field_item_list */
                        if (is_null($sparse_fieldset) || in_array($field_name, $sparse_fieldset)) {
                            $field_access = static::entityFieldAccess($entity, $field_name, 'view', $account);
                            $cacheability->addCacheableDependency($field_access);
                            if ($field_access->isAllowed()) {
                                foreach ($field_item_list as $field_item) {
                                    
                                    /** @var \Drupal\Core\Field\FieldItemInterface $field_item */
                                    foreach (TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item) as $property) {
                                        $cacheability->addCacheableDependency(CacheableMetadata::createFromObject($property));
                                    }
                                }
                            }
                        }
                    }
                }
            }
            return $cacheability;
        }, new CacheableMetadata());
        $entity_type = reset($collection)->getEntityType();
        $cacheability->addCacheTags([
            'http_response',
        ]);
        $cacheability->addCacheTags($entity_type->getListCacheTags());
        $cache_contexts = [
            // Cache contexts for JSON:API URL query parameters.
'url.query_args',
            // Drupal defaults.
'url.site',
        ];
        // If the entity type is revisionable, add a resource version cache context.
        $cache_contexts = Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? [
            'url.query_args:resourceVersion',
        ] : []);
        $cacheability->addCacheContexts($cache_contexts);
        return $cacheability;
    }
    
    /**
     * Sets up the necessary authorization.
     *
     * Because of the $method parameter, it's possible to first set up
     * authorization for only GET, then add POST, et cetera. This then also
     * allows for verifying a 403 in case of missing authorization.
     *
     * @param string $method
     *   The HTTP method for which to set up authorization.
     *
     * @see ::grantPermissionsToTestedRole()
     */
    protected abstract function setUpAuthorization($method);
    
    /**
     * Sets up the necessary authorization for handling revisions.
     *
     * @param string $method
     *   The HTTP method for which to set up authentication.
     *
     * @see ::testRevisions()
     */
    protected function setUpRevisionAuthorization($method) {
        assert($method === 'GET', 'Only read operations on revisions are supported.');
        $this->setUpAuthorization($method);
    }
    
    /**
     * Return the expected error message.
     *
     * @param string $method
     *   The HTTP method (GET, POST, PATCH, DELETE).
     *
     * @return string
     *   The error string.
     */
    protected function getExpectedUnauthorizedAccessMessage($method) {
        $permission = $this->entity
            ->getEntityType()
            ->getAdminPermission();
        if ($permission !== FALSE) {
            return "The '{$permission}' permission is required.";
        }
        return NULL;
    }
    
    /**
     * Grants permissions to the authenticated role.
     *
     * @param string[] $permissions
     *   Permissions to grant.
     */
    protected function grantPermissionsToTestedRole(array $permissions) {
        $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions);
    }
    
    /**
     * Revokes permissions from the authenticated role.
     *
     * @param string[] $permissions
     *   Permissions to revoke.
     */
    protected function revokePermissionsFromTestedRole(array $permissions) {
        $role = Role::load(RoleInterface::AUTHENTICATED_ID);
        foreach ($permissions as $permission) {
            $role->revokePermission($permission);
        }
        $role->trustData()
            ->save();
    }
    
    /**
     * Asserts that a resource response has the given status code and body.
     *
     * Cache max-age is not yet considered when expected header is calculated.
     *
     * @param int $expected_status_code
     *   The expected response status.
     * @param array|null|false $expected_document
     *   The expected document or NULL if there should not be a response body.
     *   FALSE in case this should not be asserted.
     * @param \Psr\Http\Message\ResponseInterface $response
     *   The response to assert.
     * @param string[]|false $expected_cache_tags
     *   (optional) The expected cache tags in the X-Drupal-Cache-Tags response
     *   header, or FALSE if that header should be absent. Defaults to FALSE.
     * @param string[]|false $expected_cache_contexts
     *   (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
     *   response header, or FALSE if that header should be absent. Defaults to
     *   FALSE.
     * @param 'MISS','HIT','UNCACHEABLE (request policy)','UNCACHEABLE (response policy)'|NULL $expected_page_cache_header_value
     *   (optional) The expected X-Drupal-Cache response header value, or NULL
     *   in case of no opinion on that. For possible string values, see the
     *   parameter type hint. Defaults to NULL.
     * @param 'HIT','MISS','UNCACHEABLE (poor cacheability)','UNCACHEABLE (no cacheability)','UNCACHEABLE (request policy)','UNCACHEABLE (response policy)'|bool $expected_dynamic_page_cache_header_value
     *   (optional) The expected X-Drupal-Dynamic-Cache response header value
     *   - for possible string values, see the parameter type hint - or TRUE when
     *   the value should be autogenerated from expected cache contexts, or FALSE
     *   if that header should be absent. Defaults to FALSE.
     */
    protected function assertResourceResponse($expected_status_code, $expected_document, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = NULL, $expected_dynamic_page_cache_header_value = FALSE) {
        $this->assertSame($expected_status_code, $response->getStatusCode(), var_export(Json::decode((string) $response->getBody()), TRUE));
        if ($expected_status_code === 204) {
            // DELETE responses should not include a Content-Type header. But Apache
            // sets it to 'text/html' by default. We also cannot detect the presence
            // of Apache either here in the CLI. For now having this documented here
            // is all we can do.
            
            /* $this->assertFalse($response->hasHeader('Content-Type')); */
            $this->assertSame('', (string) $response->getBody());
        }
        else {
            $this->assertSame([
                'application/vnd.api+json',
            ], $response->getHeader('Content-Type'));
            if ($expected_document !== FALSE) {
                $response_document = $this->getDocumentFromResponse($response, FALSE);
                if ($expected_document === NULL) {
                    $this->assertNull($response_document);
                }
                else {
                    $this->assertSameDocument($expected_document, $response_document);
                }
            }
        }
        // Expected cache tags: X-Drupal-Cache-Tags header.
        $this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags'));
        if (is_array($expected_cache_tags)) {
            $actual_cache_tags = explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0]);
            $tag = 'config:system.logging';
            if (!in_array($tag, $expected_cache_tags) && in_array($tag, $actual_cache_tags)) {
                $expected_cache_tags[] = $tag;
            }
            $this->assertEqualsCanonicalizing($expected_cache_tags, $actual_cache_tags);
        }
        // Expected cache contexts: X-Drupal-Cache-Contexts header.
        $this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts'));
        if (is_array($expected_cache_contexts)) {
            $optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts);
            $this->assertEqualsCanonicalizing($optimized_expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
        }
        // Expected Page Cache header value: X-Drupal-Cache header.
        if ($expected_page_cache_header_value !== NULL) {
            $this->assertTrue($response->hasHeader('X-Drupal-Cache'));
            $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]);
        }
        // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header.
        if ($expected_dynamic_page_cache_header_value === FALSE) {
            $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache'));
        }
        else {
            $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache'));
            $this->assertSame($expected_dynamic_page_cache_header_value === TRUE ? $this->generateDynamicPageCacheExpectedHeaderValue($expected_cache_contexts) : $expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
        }
    }
    
    /**
     * Asserts that an expected document matches the response body.
     *
     * @param array $expected_document
     *   The expected JSON:API document.
     * @param array $actual_document
     *   The actual response document to assert.
     */
    protected function assertSameDocument(array $expected_document, array $actual_document) {
        static::recursiveKsort($expected_document);
        static::recursiveKsort($actual_document);
        if (!empty($expected_document['included'])) {
            static::sortResourceCollection($expected_document['included']);
            static::sortResourceCollection($actual_document['included']);
        }
        if (isset($actual_document['meta']['omitted']) && isset($expected_document['meta']['omitted'])) {
            $actual_omitted =& $actual_document['meta']['omitted'];
            $expected_omitted =& $expected_document['meta']['omitted'];
            static::sortOmittedLinks($actual_omitted);
            static::sortOmittedLinks($expected_omitted);
            static::resetOmittedLinkKeys($actual_omitted);
            static::resetOmittedLinkKeys($expected_omitted);
        }
        $expected_keys = array_keys($expected_document);
        $actual_keys = array_keys($actual_document);
        $missing_member_names = array_diff($expected_keys, $actual_keys);
        $extra_member_names = array_diff($actual_keys, $expected_keys);
        if (!empty($missing_member_names) || !empty($extra_member_names)) {
            $message_format = "The document members did not match the expected values. Missing: [ %s ]. Unexpected: [ %s ]";
            $message = sprintf($message_format, implode(', ', $missing_member_names), implode(', ', $extra_member_names));
            $this->assertEquals($expected_document, $actual_document, $message);
        }
        foreach ($expected_document as $member_name => $expected_member) {
            $actual_member = $actual_document[$member_name];
            $this->assertEqualsCanonicalizing($expected_member, $actual_member, "The '{$member_name}' member was not as expected.");
        }
    }
    
    /**
     * Asserts that a resource error response has the given message.
     *
     * Cache max-age is not yet considered when expected header is calculated.
     *
     * @param int $expected_status_code
     *   The expected response status.
     * @param string $expected_message
     *   The expected error message.
     * @param \Drupal\Core\Url|null $via_link
     *   The source URL for the errors of the response. NULL if the error occurs
     *   for example during entity creation.
     * @param \Psr\Http\Message\ResponseInterface $response
     *   The error response to assert.
     * @param string|false $pointer
     *   The expected JSON Pointer to the associated entity in the request
     *   document. See http://jsonapi.org/format/#error-objects.
     * @param string[]|false $expected_cache_tags
     *   (optional) The expected cache tags in the X-Drupal-Cache-Tags response
     *   header, or FALSE if that header should be absent. Defaults to FALSE.
     * @param string[]|false $expected_cache_contexts
     *   (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
     *   response header, or FALSE if that header should be absent. Defaults to
     *   FALSE.
     * @param 'MISS','HIT','UNCACHEABLE (request policy)','UNCACHEABLE (response policy)'|NULL $expected_page_cache_header_value
     *   (optional) The expected X-Drupal-Cache response header value, or NULL
     *   in case of no opinion on that. For possible string values, see the
     *   parameter type hint. Defaults to NULL.
     * @param 'HIT','MISS','UNCACHEABLE (poor cacheability)','UNCACHEABLE (no cacheability)','UNCACHEABLE (request policy)','UNCACHEABLE (response policy)'|bool $expected_dynamic_page_cache_header_value
     *   (optional) The expected X-Drupal-Dynamic-Cache response header value
     *   - for possible string values, see the parameter type hint - or TRUE when
     *   the value should be autogenerated from expected cache contexts, or FALSE
     *   if that header should be absent. Defaults to FALSE.
     */
    protected function assertResourceErrorResponse($expected_status_code, $expected_message, $via_link, ResponseInterface $response, $pointer = FALSE, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = NULL, $expected_dynamic_page_cache_header_value = FALSE) {
        assert(is_null($via_link) || $via_link instanceof Url);
        $expected_error = [];
        if (!empty(Response::$statusTexts[$expected_status_code])) {
            $expected_error['title'] = Response::$statusTexts[$expected_status_code];
        }
        $expected_error['status'] = (string) $expected_status_code;
        $expected_error['detail'] = $expected_message;
        if ($via_link) {
            $expected_error['links']['via']['href'] = $via_link->setAbsolute()
                ->toString();
        }
        if ($info_url = HttpExceptionNormalizer::getInfoUrl($expected_status_code)) {
            $expected_error['links']['info']['href'] = $info_url;
        }
        if ($pointer !== FALSE) {
            $expected_error['source']['pointer'] = $pointer;
        }
        $expected_document = [
            'jsonapi' => static::$jsonApiMember,
            'errors' => [
                0 => $expected_error,
            ],
        ];
        $this->assertResourceResponse($expected_status_code, $expected_document, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value);
    }
    
    /**
     * Makes the JSON:API document violate the spec by omitting the resource type.
     *
     * @param array $document
     *   A JSON:API document.
     *
     * @return array
     *   The same JSON:API document, without its resource type.
     */
    protected function removeResourceTypeFromDocument(array $document) {
        unset($document['data']['type']);
        return $document;
    }
    
    /**
     * Makes the given JSON:API document invalid.
     *
     * @param array $document
     *   A JSON:API document.
     * @param string $entity_key
     *   The entity key whose normalization to make invalid.
     *
     * @return array
     *   The updated JSON:API document, now invalid.
     */
    protected function makeNormalizationInvalid(array $document, $entity_key) {
        $entity_type = $this->entity
            ->getEntityType();
        switch ($entity_key) {
            case 'label':
                // Add a second label to this entity to make it invalid.
                $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName;
                $document['data']['attributes'][$label_field] = [
                    0 => $document['data']['attributes'][$label_field],
                    1 => 'Second Title',
                ];
                break;
            case 'id':
                $document['data']['attributes'][$entity_type->getKey('id')] = $this->anotherEntity
                    ->id();
                break;
            case 'uuid':
                $document['data']['id'] = $this->anotherEntity
                    ->uuid();
                break;
        }
        return $document;
    }
    
    /**
     * Tests GETting an individual resource, plus edge cases to ensure good DX.
     */
    public function testGetIndividual() : void {
        // 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: 403 when unauthorized, or 200 if the 'view label' operation is
        // supported by the entity type.
        $response = $this->request('GET', $url, $request_options);
        $dynamic_cache_label_only = NULL;
        if (!static::$anonymousUsersCanViewLabels) {
            $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
            $reason = $this->getExpectedUnauthorizedAccessMessage('GET');
            $message = trim("The current user is not allowed to GET the selected resource. {$reason}");
            $this->assertResourceErrorResponse(403, $message, $url, $response, '/data', $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', TRUE);
            $this->assertArrayNotHasKey('Link', $response->getHeaders());
        }
        else {
            $expected_document = $this->getExpectedDocument();
            $label_field_name = $this->entity
                ->getEntityType()
                ->hasKey('label') ? $this->entity
                ->getEntityType()
                ->getKey('label') : static::$labelFieldName;
            $expected_document['data']['attributes'] = array_intersect_key($expected_document['data']['attributes'], [
                $label_field_name => TRUE,
            ]);
            unset($expected_document['data']['relationships']);
            $dynamic_cache_label_only = $this->generateDynamicPageCacheExpectedHeaderValue($this->getExpectedCacheContexts([
                $label_field_name,
            ]));
            $this->assertResourceResponse(200, $expected_document, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts([
                $label_field_name,
            ]), 'UNCACHEABLE (request policy)', TRUE);
        }
        $this->setUpAuthorization('GET');
        // Set body despite that being nonsensical: should be ignored.
        $request_options[RequestOptions::BODY] = Json::encode($this->getExpectedDocument());
        // 400 for GET request with reserved custom query parameter.
        $url_reserved_custom_query_parameter = clone $url;
        $url_reserved_custom_query_parameter = $url_reserved_custom_query_parameter->setOption('query', [
            'foo' => 'bar',
        ]);
        $response = $this->request('GET', $url_reserved_custom_query_parameter, $request_options);
        $expected_document = [
            'jsonapi' => static::$jsonApiMember,
            'errors' => [
                [
                    'title' => 'Bad Request',
                    'status' => '400',
                    'detail' => "The following query parameters violate the JSON:API spec: 'foo'.",
                    'links' => [
                        'info' => [
                            'href' => 'http://jsonapi.org/format/#query-parameters',
                        ],
                        'via' => [
                            'href' => $url_reserved_custom_query_parameter->toString(),
                        ],
                    ],
                ],
            ],
        ];
        $this->assertResourceResponse(400, $expected_document, $response, [
            '4xx-response',
            'http_response',
        ], [
            'url.query_args',
            'url.site',
        ], 'UNCACHEABLE (request policy)', TRUE);
        // 200 for well-formed HEAD request.
        $response = $this->request('HEAD', $url, $request_options);
        $this->assertResourceResponse(200, NULL, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), 'UNCACHEABLE (request policy)', TRUE);
        $head_headers = $response->getHeaders();
        // 200 for well-formed GET request. Page Cache hit because of HEAD request.
        // Same for Dynamic Page Cache hit.
        $response = $this->request('GET', $url, $request_options);
        $this->assertResourceResponse(200, $this->getExpectedDocument(), $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), 'UNCACHEABLE (request policy)', $this->generateDynamicPageCacheExpectedHeaderValue($this->getExpectedCacheContexts()) === 'MISS' ? 'HIT' : 'UNCACHEABLE (poor cacheability)');
        // Assert that Dynamic Page Cache did not store a ResourceResponse object,
        // which needs serialization after every cache hit. Instead, it should
        // contain a flattened response. Otherwise performance suffers.
        // @see \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
        $cache_items = $this->container
            ->get('database')
            ->select('cache_dynamic_page_cache', 'cdp')
            ->fields('cdp', [
            'data',
        ])
            ->condition('cid', '%[route]=jsonapi.%', 'LIKE')
            ->execute()
            ->fetchAll();
        $this->assertLessThanOrEqual(5, count($cache_items));
        $found_cached_200_response = FALSE;
        $other_cached_responses_are_4xx = TRUE;
        foreach ($cache_items as $cache_item) {
            $cached_response = unserialize($cache_item->data);
            if (!$cached_response instanceof CacheRedirect) {
                if ($cached_response->getStatusCode() === 200) {
                    $found_cached_200_response = TRUE;
                }
                elseif (!$cached_response->isClientError()) {
                    $other_cached_responses_are_4xx = FALSE;
                }
                $this->assertNotInstanceOf(ResourceResponse::class, $cached_response);
                $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
            }
        }
        $this->assertSame($this->generateDynamicPageCacheExpectedHeaderValue($this->getExpectedCacheContexts()) !== 'UNCACHEABLE (poor cacheability)' || $dynamic_cache_label_only !== 'UNCACHEABLE (poor cacheability)', $found_cached_200_response);
        $this->assertTrue($other_cached_responses_are_4xx);
        // Not only assert the normalization, also assert deserialization of the
        // response results in the expected object.
        $unserialized = $this->serializer
            ->deserialize((string) $response->getBody(), JsonApiDocumentTopLevel::class, 'api_json', [
            'target_entity' => static::$entityTypeId,
            'resource_type' => $this->container
                ->get('jsonapi.resource_type.repository')
                ->getByTypeName(static::$resourceTypeName),
        ]);
        $this->assertSame($unserialized->uuid(), $this->entity
            ->uuid());
        $get_headers = $response->getHeaders();
        // Verify that the GET and HEAD responses are the same. The only difference
        // is that there's no body. For this reason the 'Transfer-Encoding' and
        // 'Vary' headers are also added to the list of headers to ignore, as they
        // may be added to GET requests, depending on web server configuration. They
        // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
        $ignored_headers = [
            'Date',
            'Content-Length',
            'X-Drupal-Cache',
            'X-Drupal-Dynamic-Cache',
            'Transfer-Encoding',
            'Vary',
        ];
        $header_cleaner = function ($headers) use ($ignored_headers) {
            foreach ($headers as $header => $value) {
                if (str_starts_with($header, 'X-Drupal-Assertion-') || in_array($header, $ignored_headers)) {
                    unset($headers[$header]);
                }
            }
            return $headers;
        };
        $get_headers = $header_cleaner($get_headers);
        $head_headers = $header_cleaner($head_headers);
        $this->assertSame($get_headers, $head_headers);
        // Feature: Sparse fieldsets.
        $this->doTestSparseFieldSets($url, $request_options);
        // Feature: Included.
        $this->doTestIncluded($url, $request_options);
        // DX: 404 when GETting non-existing entity.
        $random_uuid = \Drupal::service('uuid')->generate();
        $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [
            'entity' => $random_uuid,
        ]);
        $response = $this->request('GET', $url, $request_options);
        $message_url = clone $url;
        $path = str_replace($random_uuid, '{entity}', $message_url->setAbsolute()
            ->setOptions([
            'base_url' => '',
            'query' => [],
        ])
            ->toString());
        $message = 'The "entity" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")';
        $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, [
            '4xx-response',
            'http_response',
        ], [
            'url.query_args',
            'url.site',
        ], 'UNCACHEABLE (request policy)', 'UNCACHEABLE (poor cacheability)');
        // DX: when Accept request header is missing, still 404, same response.
        unset($request_options[RequestOptions::HEADERS]['Accept']);
        $response = $this->request('GET', $url, $request_options);
        $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, [
            '4xx-response',
            'http_response',
        ], [
            'url.query_args',
            'url.site',
        ], 'UNCACHEABLE (request policy)', 'UNCACHEABLE (poor cacheability)');
    }
    
    /**
     * Tests GETting a collection of resources.
     */
    public function testCollection() : void {
        $entity_collection = $this->getData();
        assert(count($entity_collection) > 1, 'A collection must have more that one entity in it.');
        $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute(TRUE);
        $request_options = [];
        $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
        $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
        // This asserts that collections will work without a sort, added by default
        // below, without actually asserting the content of the response.
        $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $response = $this->request('HEAD', $collection_url, $request_options);
        $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge()));
        // Different databases have different sort orders, so a sort is required so
        // test expectations do not need to vary per database.
        $default_sort = [
            'sort' => 'drupal_internal__' . $this->entity
                ->getEntityType()
                ->getKey('id'),
        ];
        $collection_url->setOption('query', $default_sort);
        // 200 for collections, even when all entities are inaccessible. Access is
        // on a per-entity basis, which is handled by
        // self::getExpectedCollectionResponse().
        $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $expected_document = $expected_response->getResponseData();
        $response = $this->request('GET', $collection_url, $request_options);
        $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge()));
        $this->setUpAuthorization('GET');
        // 200 for well-formed HEAD request.
        $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $response = $this->request('HEAD', $collection_url, $request_options);
        $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge()));
        // 200 for well-formed GET request.
        $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $expected_document = $expected_response->getResponseData();
        $response = $this->request('GET', $collection_url, $request_options);
        // Dynamic Page Cache HIT unless the HEAD request was UNCACHEABLE.
        $dynamic_cache = $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge()) === 'UNCACHEABLE (poor cacheability)' ? 'UNCACHEABLE (poor cacheability)' : 'HIT';
        $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
        if ($this->entity instanceof FieldableEntityInterface) {
            // 403 for filtering on an unauthorized field on the base resource type.
            $unauthorized_filter_url = clone $collection_url;
            $unauthorized_filter_url->setOption('query', [
                'filter' => [
                    'related_author_id' => [
                        'operator' => '<>',
                        'path' => 'field_jsonapi_test_entity_ref.status',
                        'value' => 'does_not@matter.com',
                    ],
                ],
            ]);
            $response = $this->request('GET', $unauthorized_filter_url, $request_options);
            $expected_error_message = "The current user is not authorized to filter by the `field_jsonapi_test_entity_ref` field, given in the path `field_jsonapi_test_entity_ref`. The 'field_jsonapi_test_entity_ref view access' permission is required.";
            $expected_cache_tags = [
                '4xx-response',
                'http_response',
            ];
            $expected_cache_contexts = [
                'url.query_args',
                'url.site',
                'user.permissions',
            ];
            $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, 'UNCACHEABLE (request policy)', TRUE);
            $this->grantPermissionsToTestedRole([
                'field_jsonapi_test_entity_ref view access',
            ]);
            // 403 for filtering on an unauthorized field on a related resource type.
            $response = $this->request('GET', $unauthorized_filter_url, $request_options);
            $expected_error_message = "The current user is not authorized to filter by the `status` field, given in the path `field_jsonapi_test_entity_ref.entity:user.status`.";
            $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, 'UNCACHEABLE (request policy)', TRUE);
        }
        // Remove an entity from the collection, then filter it out.
        $filtered_entity_collection = $entity_collection;
        $removed = array_shift($filtered_entity_collection);
        $filtered_collection_url = clone $collection_url;
        $entity_collection_filter = [
            'filter' => [
                'ids' => [
                    'condition' => [
                        'operator' => '<>',
                        'path' => 'id',
                        'value' => $removed->uuid(),
                    ],
                ],
            ],
        ];
        $filtered_collection_url->setOption('query', $entity_collection_filter + $default_sort);
        $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_url->toString(), $request_options, NULL, TRUE);
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $expected_document = $expected_response->getResponseData();
        $response = $this->request('GET', $filtered_collection_url, $request_options);
        $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge()));
        // Filtered collection with includes.
        $relationship_field_names = array_reduce($filtered_entity_collection, function ($relationship_field_names, $entity) {
            return array_unique(array_merge($relationship_field_names, $this->getRelationshipFieldNames($entity)));
        }, []);
        $include = [
            'include' => implode(',', $relationship_field_names),
        ];
        $filtered_collection_include_url = clone $collection_url;
        $filtered_collection_include_url->setOption('query', $entity_collection_filter + $include + $default_sort);
        $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE);
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), [
            '4xx-response',
        ])));
        $expected_document = $expected_response->getResponseData();
        $response = $this->request('GET', $filtered_collection_include_url, $request_options);
        $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge()));
        // If the response should vary by a user's authorizations, grant permissions
        // for the included resources and execute another request.
        $permission_related_cache_contexts = [
            'user',
            'user.permissions',
            'user.roles',
        ];
        if (!empty($relationship_field_names) && !empty(array_intersect($expected_cacheability->getCacheContexts(), $permission_related_cache_contexts))) {
            $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($relationship_field_names));
            $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', []));
            $this->grantPermissionsToTestedRole($flattened_permissions);
            $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE);
            $expected_cacheability = $expected_response->getCacheableMetadata();
            $expected_document = $expected_response->getResponseData();
            $response = $this->request('GET', $filtered_collection_include_url, $request_options);
            $requires_include_only_permissions = !empty($flattened_permissions);
            $is_uncacheable = $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge()) === 'UNCACHEABLE (poor cacheability)';
            $dynamic_cache = !$is_uncacheable ? $requires_include_only_permissions ? 'MISS' : 'HIT' : 'UNCACHEABLE (poor cacheability)';
            $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
        }
        // Sorted collection with includes.
        $sorted_entity_collection = $entity_collection;
        uasort($sorted_entity_collection, function (EntityInterface $a, EntityInterface $b) {
            // Sort by ID in reverse order.
            return strcmp($b->uuid(), $a->uuid());
        });
        $sorted_collection_include_url = clone $collection_url;
        $sorted_collection_include_url->setOption('query', $include + [
            'sort' => "-id",
        ]);
        $expected_response = $this->getExpectedCollectionResponse($sorted_entity_collection, $sorted_collection_include_url->toString(), $request_options, $relationship_field_names);
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), [
            '4xx-response',
        ])));
        $expected_document = $expected_response->getResponseData();
        $response = $this->request('GET', $sorted_collection_include_url, $request_options);
        $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $this->generateDynamicPageCacheExpectedHeaderValue([], $expected_cacheability->getCacheMaxAge()));
    }
    
    /**
     * Returns a JSON:API collection document for the expected entities.
     *
     * @param \Drupal\Core\Entity\EntityInterface[] $collection
     *   The entities for the collection.
     * @param string $self_link
     *   The self link for the collection response document.
     * @param array $request_options
     *   Request options to apply.
     * @param array|null $included_paths
     *   (optional) Any include paths that should be appended to the expected
     *   response.
     * @param bool $filtered
     *   Whether the collection is filtered or not.
     *
     * @return \Drupal\jsonapi\ResourceResponse
     *   A ResourceResponse for the expected entity collection.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     */
    protected function getExpectedCollectionResponse(array $collection, $self_link, array $request_options, ?array $included_paths = NULL, $filtered = FALSE) {
        $resource_identifiers = array_map([
            static::class,
            'toResourceIdentifier',
        ], $collection);
        $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options));
        $merged_response = static::toCollectionResourceResponse($individual_responses, $self_link, TRUE);
        $merged_document = $merged_response->getResponseData();
        if (!isset($merged_document['data'])) {
            $merged_document['data'] = [];
        }
        $cacheability = static::getExpectedCollectionCacheability($this->account, $collection, NULL, $filtered);
        $cacheability->setCacheMaxAge($merged_response->getCacheableMetadata()
            ->getCacheMaxAge());
        $collection_response = new CacheableResourceResponse($merged_document);
        $collection_response->addCacheableDependency($cacheability);
        if (is_null($included_paths)) {
            return $collection_response;
        }
        $related_responses = array_reduce($collection, function ($related_responses, EntityInterface $entity) use ($included_paths, $request_options, $self_link) {
            if (!$entity->access('view', $this->account) && !$entity->access('view label', $this->account)) {
                return $related_responses;
            }
            $expected_related_responses = $this->getExpectedRelatedResponses($included_paths, $request_options, $entity);
            if (empty($related_responses)) {
                return $expected_related_responses;
            }
            foreach ($included_paths as $included_path) {
                $both_responses = [
                    $related_responses[$included_path],
                    $expected_related_responses[$included_path],
                ];
                $related_responses[$included_path] = static::toCollectionResourceResponse($both_responses, $self_link, TRUE);
            }
            return $related_responses;
        }, []);
        return static::decorateExpectedResponseForIncludedFields($collection_response, $related_responses);
    }
    
    /**
     * Tests GET of the related resource of an individual resource.
     *
     * Expected responses are built by making requests to 'relationship' routes.
     * Using the fetched resource identifiers, if any, all targeted resources are
     * fetched individually. These individual responses are then 'merged' into a
     * single expected ResourceResponse. This is repeated for every relationship
     * field of the resource type under test.
     */
    public function testRelated() : void {
        $request_options = [];
        $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
        $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
        $this->doTestRelated($request_options);
        $this->setUpAuthorization('GET');
        $this->doTestRelated($request_options);
    }
    
    /**
     * Tests CRUD of individual resource relationship data.
     *
     * Unlike the "related" routes, relationship routes only return information
     * about the "relationship" itself, not the targeted resources. For JSON:API
     * with Drupal, relationship routes are like looking at an entity reference
     * field without loading the entities. It only reveals the type of the
     * targeted resource and the target resource IDs. These type+ID combos are
     * referred to as "resource identifiers."
     */
    public function testRelationships() : void {
        if ($this->entity instanceof ConfigEntityInterface) {
            $this->markTestSkipped('Configuration entities cannot have relationships.');
        }
        $request_options = [];
        $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
        $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
        // Test GET.
        $this->doTestRelationshipGet($request_options);
        $this->setUpAuthorization('GET');
        $this->doTestRelationshipGet($request_options);
        // Test POST.
        $this->config('jsonapi.settings')
            ->set('read_only', FALSE)
            ->save(TRUE);
        $this->doTestRelationshipMutation($request_options);
        // Grant entity-level edit access.
        $this->setUpAuthorization('PATCH');
        $this->doTestRelationshipMutation($request_options);
        // Field edit access is still forbidden, grant it.
        $this->grantPermissionsToTestedRole([
            'field_jsonapi_test_entity_ref view access',
            'field_jsonapi_test_entity_ref edit access',
            'field_jsonapi_test_entity_ref update access',
        ]);
        $this->doTestRelationshipMutation($request_options);
    }
    
    /**
     * Performs one round of related route testing.
     *
     * By putting this behavior in its own method, authorization and other
     * variations can be done in the calling method around assertions. For
     * example, it can be run once with an authorized user and again without one.
     *
     * @param array $request_options
     *   Request options to apply.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     */
    protected function doTestRelated(array $request_options) {
        $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
        // If there are no relationship fields, we can't test related routes.
        if (empty($relationship_field_names)) {
            return;
        }
        // Builds an array of expected responses, keyed by relationship field name.
        $expected_relationship_responses = $this->getExpectedRelatedResponses($relationship_field_names, $request_options);
        // Fetches actual responses as an array keyed by relationship field name.
        $related_responses = $this->getRelatedResponses($relationship_field_names, $request_options);
        foreach ($relationship_field_names as $relationship_field_name) {
            
            /** @var \Drupal\jsonapi\ResourceResponse $expected_resource_response */
            $expected_resource_response = $expected_relationship_responses[$relationship_field_name];
            
            /** @var \Psr\Http\Message\ResponseInterface $actual_response */
            $actual_response = $related_responses[$relationship_field_name];
            // Dynamic Page Cache miss because cache should vary based on the
            // 'include' query param.
            $expected_cacheability = $expected_resource_response->getCacheableMetadata();
            $expected_dynamic_page_cache_header_value = $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge());
            $this->assertResourceResponse($expected_resource_response->getStatusCode(), $expected_resource_response->getResponseData(), $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, $actual_response->getStatusCode() === 200 ? $expected_dynamic_page_cache_header_value : ($expected_dynamic_page_cache_header_value === 'MISS' ? FALSE : $expected_dynamic_page_cache_header_value));
        }
    }
    
    /**
     * Performs one round of relationship route testing.
     *
     * @param array $request_options
     *   Request options to apply.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     * @see ::testRelationships
     */
    protected function doTestRelationshipGet(array $request_options) {
        $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
        // If there are no relationship fields, we can't test relationship routes.
        if (empty($relationship_field_names)) {
            return;
        }
        // Test GET.
        $related_responses = $this->getRelationshipResponses($relationship_field_names, $request_options);
        foreach ($relationship_field_names as $relationship_field_name) {
            $expected_resource_response = $this->getExpectedGetRelationshipResponse($relationship_field_name);
            $expected_document = $expected_resource_response->getResponseData();
            $expected_cacheability = $expected_resource_response->getCacheableMetadata();
            $actual_response = $related_responses[$relationship_field_name];
            $expected_dynamic_page_cache_header_value = $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts(), $expected_cacheability->getCacheMaxAge());
            $this->assertResourceResponse($expected_resource_response->getStatusCode(), $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, $expected_dynamic_page_cache_header_value === 'MISS' && !$expected_resource_response->isSuccessful() ? FALSE : $expected_dynamic_page_cache_header_value);
        }
    }
    
    /**
     * Performs one round of relationship POST, PATCH and DELETE route testing.
     *
     * @param array $request_options
     *   Request options to apply.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     * @see ::testRelationships
     */
    protected function doTestRelationshipMutation(array $request_options) {
        
        /** @var \Drupal\Core\Entity\FieldableEntityInterface $resource */
        $resource = $this->createAnotherEntity('dupe');
        $resource->set('field_jsonapi_test_entity_ref', NULL);
        $violations = $resource->validate();
        assert($violations->count() === 0, (string) $violations);
        $resource->save();
        $target_resource = $this->createUser();
        $violations = $target_resource->validate();
        assert($violations->count() === 0, (string) $violations);
        $target_resource->save();
        $target_identifier = static::toResourceIdentifier($target_resource);
        $resource_identifier = static::toResourceIdentifier($resource);
        $relationship_field_name = 'field_jsonapi_test_entity_ref';
        
        /** @var \Drupal\Core\Access\AccessResultReasonInterface $update_access */
        $update_access = static::entityAccess($resource, 'update', $this->account)
            ->andIf(static::entityFieldAccess($resource, $relationship_field_name, 'edit', $this->account));
        $url = Url::fromRoute(sprintf("jsonapi.{$resource_identifier['type']}.{$relationship_field_name}.relationship.patch"), [
            'entity' => $resource->uuid(),
        ]);
        // Test POST: missing content-type.
        $response = $this->request('POST', $url, $request_options);
        $this->assertSame(415, $response->getStatusCode());
        // Set the JSON:API media type header for all subsequent requests.
        $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
        if ($update_access->isAllowed()) {
            // Test POST: empty body.
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
            // Test PATCH: empty body.
            $response = $this->request('PATCH', $url, $request_options);
            $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
            // Test POST: empty data.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [],
            ]);
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceResponse(204, NULL, $response);
            // Test PATCH: empty data.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [],
            ]);
            $response = $this->request('PATCH', $url, $request_options);
            $this->assertResourceResponse(204, NULL, $response);
            // Test POST: data as resource identifier, not array of identifiers.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => $target_identifier,
            ]);
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
            // Test PATCH: data as resource identifier, not array of identifiers.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => $target_identifier,
            ]);
            $response = $this->request('PATCH', $url, $request_options);
            $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
            // Test POST: missing the 'type' field.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => array_intersect_key($target_identifier, [
                    'id' => 'id',
                ]),
            ]);
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
            // Test PATCH: missing the 'type' field.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => array_intersect_key($target_identifier, [
                    'id' => 'id',
                ]),
            ]);
            $response = $this->request('PATCH', $url, $request_options);
            $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
            // If the base resource type is the same as that of the target's (as it
            // will be for `user--user`), then the validity error will not be
            // triggered, needlessly failing this assertion.
            if (static::$resourceTypeName !== $target_identifier['type']) {
                // Test POST: invalid target.
                $request_options[RequestOptions::BODY] = Json::encode([
                    'data' => [
                        $resource_identifier,
                    ],
                ]);
                $response = $this->request('POST', $url, $request_options);
                $this->assertResourceErrorResponse(400, sprintf('The provided type (%s) does not match the destination resource types (%s).', $resource_identifier['type'], $target_identifier['type']), $url, $response, FALSE);
                // Test PATCH: invalid target.
                $request_options[RequestOptions::BODY] = Json::encode([
                    'data' => [
                        $resource_identifier,
                    ],
                ]);
                $response = $this->request('POST', $url, $request_options);
                $this->assertResourceErrorResponse(400, sprintf('The provided type (%s) does not match the destination resource types (%s).', $resource_identifier['type'], $target_identifier['type']), $url, $response, FALSE);
            }
            // Test POST: duplicate targets, no arity.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier,
                    $target_identifier,
                ],
            ]);
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE);
            // Test PATCH: duplicate targets, no arity.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier,
                    $target_identifier,
                ],
            ]);
            $response = $this->request('PATCH', $url, $request_options);
            $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE);
            // Test POST: success.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier,
                ],
            ]);
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceResponse(204, NULL, $response);
            // Test POST: success, relationship already exists, no arity.
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceResponse(204, NULL, $response);
            // Test POST: success, relationship already exists, new arity.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier + [
                        'meta' => [
                            'arity' => 1,
                        ],
                    ],
                ],
            ]);
            $response = $this->request('POST', $url, $request_options);
            $resource->set($relationship_field_name, [
                $target_resource,
                $target_resource,
            ]);
            $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
            $expected_document['data'][0] += [
                'meta' => [
                    'arity' => 0,
                ],
            ];
            $expected_document['data'][1] += [
                'meta' => [
                    'arity' => 1,
                ],
            ];
            $this->assertResourceResponse(200, $expected_document, $response);
            // Test PATCH: success, new value is the same as given value.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier + [
                        'meta' => [
                            'arity' => 0,
                        ],
                    ],
                    $target_identifier + [
                        'meta' => [
                            'arity' => 1,
                        ],
                    ],
                ],
            ]);
            $response = $this->request('PATCH', $url, $request_options);
            $this->assertResourceResponse(204, NULL, $response);
            // Test POST: success, relationship already exists, new arity.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier + [
                        'meta' => [
                            'arity' => 2,
                        ],
                    ],
                ],
            ]);
            $response = $this->request('POST', $url, $request_options);
            $resource->set($relationship_field_name, [
                $target_resource,
                $target_resource,
                $target_resource,
            ]);
            $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
            $expected_document['data'][0] += [
                'meta' => [
                    'arity' => 0,
                ],
            ];
            $expected_document['data'][1] += [
                'meta' => [
                    'arity' => 1,
                ],
            ];
            $expected_document['data'][2] += [
                'meta' => [
                    'arity' => 2,
                ],
            ];
            // 200 with response body because the request did not include the
            // existing relationship resource identifier object.
            $this->assertResourceResponse(200, $expected_document, $response);
            // Test POST: success.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier + [
                        'meta' => [
                            'arity' => 0,
                        ],
                    ],
                    $target_identifier + [
                        'meta' => [
                            'arity' => 1,
                        ],
                    ],
                ],
            ]);
            $response = $this->request('POST', $url, $request_options);
            // 200 with response body because the request did not include the
            // resource identifier with arity 2.
            $this->assertResourceResponse(200, $expected_document, $response);
            // Test PATCH: success.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier + [
                        'meta' => [
                            'arity' => 0,
                        ],
                    ],
                    $target_identifier + [
                        'meta' => [
                            'arity' => 1,
                        ],
                    ],
                    $target_identifier + [
                        'meta' => [
                            'arity' => 2,
                        ],
                    ],
                ],
            ]);
            $response = $this->request('PATCH', $url, $request_options);
            // 204 no content. PATCH data matches existing data.
            $this->assertResourceResponse(204, NULL, $response);
            // Test DELETE: three existing relationships, two removed.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier + [
                        'meta' => [
                            'arity' => 0,
                        ],
                    ],
                    $target_identifier + [
                        'meta' => [
                            'arity' => 2,
                        ],
                    ],
                ],
            ]);
            $response = $this->request('DELETE', $url, $request_options);
            $this->assertResourceResponse(204, NULL, $response);
            // Subsequent GET should return only one resource identifier, with no
            // arity.
            $resource->set($relationship_field_name, [
                $target_resource,
            ]);
            $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
            $response = $this->request('GET', $url, $request_options);
            $document = $this->getDocumentFromResponse($response);
            $this->assertSameDocument($expected_document, $document);
            // Test DELETE: one existing relationship, removed.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier,
                ],
            ]);
            $response = $this->request('DELETE', $url, $request_options);
            $resource->set($relationship_field_name, []);
            $this->assertResourceResponse(204, NULL, $response);
            $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
            $response = $this->request('GET', $url, $request_options);
            $document = $this->getDocumentFromResponse($response);
            $this->assertSameDocument($expected_document, $document);
            // Test DELETE: no existing relationships, no op, success.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier,
                ],
            ]);
            $response = $this->request('DELETE', $url, $request_options);
            $this->assertResourceResponse(204, NULL, $response);
            $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
            $response = $this->request('GET', $url, $request_options);
            $document = $this->getDocumentFromResponse($response);
            $this->assertSameDocument($expected_document, $document);
            // Test PATCH: success, new value is different than existing value.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier + [
                        'meta' => [
                            'arity' => 2,
                        ],
                    ],
                    $target_identifier + [
                        'meta' => [
                            'arity' => 3,
                        ],
                    ],
                ],
            ]);
            $response = $this->request('PATCH', $url, $request_options);
            $resource->set($relationship_field_name, [
                $target_resource,
                $target_resource,
            ]);
            $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
            $expected_document['data'][0] += [
                'meta' => [
                    'arity' => 0,
                ],
            ];
            $expected_document['data'][1] += [
                'meta' => [
                    'arity' => 1,
                ],
            ];
            // 200 with response body because arity values are computed; that means
            // that the PATCH arity values 2 + 3 will become 0 + 1 if there are not
            // already resource identifiers with those arity values.
            $this->assertResourceResponse(200, $expected_document, $response);
            // Test DELETE: two existing relationships, both removed because no arity
            // was specified.
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier,
                ],
            ]);
            $response = $this->request('DELETE', $url, $request_options);
            $resource->set($relationship_field_name, []);
            $this->assertResourceResponse(204, NULL, $response);
            $resource->set($relationship_field_name, []);
            $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
            $response = $this->request('GET', $url, $request_options);
            $document = $this->getDocumentFromResponse($response);
            $this->assertSameDocument($expected_document, $document);
        }
        else {
            $request_options[RequestOptions::BODY] = Json::encode([
                'data' => [
                    $target_identifier,
                ],
            ]);
            $response = $this->request('POST', $url, $request_options);
            $message = 'The current user is not allowed to edit this relationship.';
            $message .= ($reason = $update_access->getReason()) ? ' ' . $reason : '';
            $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
            $response = $this->request('PATCH', $url, $request_options);
            $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
            $response = $this->request('DELETE', $url, $request_options);
            $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
        }
        // Remove the test entities that were created.
        $resource->delete();
        $target_resource->delete();
    }
    
    /**
     * Gets an expected ResourceResponse for the given relationship.
     *
     * @param string $relationship_field_name
     *   The relationship for which to get an expected response.
     * @param \Drupal\Core\Entity\EntityInterface|null $entity
     *   (optional) The entity for which to get expected relationship response.
     *
     * @return \Drupal\jsonapi\CacheableResourceResponse
     *   The expected ResourceResponse.
     */
    protected function getExpectedGetRelationshipResponse($relationship_field_name, ?EntityInterface $entity = NULL) {
        $entity = $entity ?: $this->entity;
        $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()
            ->isRevisionable() ? [
            'url.query_args',
        ] : []);
        $access = $access->orIf(static::entityFieldAccess($entity, $this->resourceType
            ->getInternalName($relationship_field_name), 'view', $this->account));
        if (!$access->isAllowed()) {
            $via_link = Url::fromRoute(sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, $relationship_field_name), [
                'entity' => $entity->uuid(),
            ]);
            return static::getAccessDeniedResponse($this->entity, $access, $via_link, $relationship_field_name, 'The current user is not allowed to view this relationship.', FALSE);
        }
        $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $entity);
        $expected_cacheability = (new CacheableMetadata())->addCacheTags([
            'http_response',
        ])
            ->addCacheContexts([
            'url.site',
            'url.query_args',
        ])
            ->addCacheableDependency($entity)
            ->addCacheableDependency($access);
        $status_code = $expected_document['errors'][0]['status'] ?? 200;
        $resource_response = new CacheableResourceResponse($expected_document, $status_code);
        $resource_response->addCacheableDependency($expected_cacheability);
        return $resource_response;
    }
    
    /**
     * Gets an expected document for the given relationship.
     *
     * @param string $relationship_field_name
     *   The relationship for which to get an expected response.
     * @param \Drupal\Core\Entity\EntityInterface|null $entity
     *   (optional) The entity for which to get expected relationship document.
     *
     * @return array
     *   The expected document array.
     */
    protected function getExpectedGetRelationshipDocument($relationship_field_name, ?EntityInterface $entity = NULL) {
        $entity = $entity ?: $this->entity;
        $entity_type_id = $entity->getEntityTypeId();
        $bundle = $entity->bundle();
        $id = $entity->uuid();
        $self_link = Url::fromUri("base:/jsonapi/{$entity_type_id}/{$bundle}/{$id}/relationships/{$relationship_field_name}")->setAbsolute();
        $related_link = Url::fromUri("base:/jsonapi/{$entity_type_id}/{$bundle}/{$id}/{$relationship_field_name}")->setAbsolute();
        if (static::$resourceTypeIsVersionable) {
            assert($entity instanceof RevisionableInterface);
            $version_query = [
                'resourceVersion' => 'id:' . $entity->getRevisionId(),
            ];
            $related_link->setOption('query', $version_query);
        }
        $data = $this->getExpectedGetRelationshipDocumentData($relationship_field_name, $entity);
        return [
            'data' => $data,
            'jsonapi' => static::$jsonApiMember,
            'links' => [
                'self' => [
                    'href' => $self_link->toString(TRUE)
                        ->getGeneratedUrl(),
                ],
                'related' => [
                    'href' => $related_link->toString(TRUE)
                        ->getGeneratedUrl(),
                ],
            ],
        ];
    }
    
    /**
     * Gets the expected document data for the given relationship.
     *
     * @param string $relationship_field_name
     *   The relationship for which to get an expected response.
     * @param \Drupal\Core\Entity\EntityInterface|null $entity
     *   (optional) The entity for which to get expected relationship data.
     *
     * @return mixed
     *   The expected document data.
     */
    protected function getExpectedGetRelationshipDocumentData($relationship_field_name, ?EntityInterface $entity = NULL) {
        $entity = $entity ?: $this->entity;
        $internal_field_name = $this->resourceType
            ->getInternalName($relationship_field_name);
        
        /** @var \Drupal\Core\Field\FieldItemListInterface $field */
        $field = $entity->{$internal_field_name};
        $is_multiple = $field->getFieldDefinition()
            ->getFieldStorageDefinition()
            ->getCardinality() !== 1;
        if ($field->isEmpty()) {
            return $is_multiple ? [] : NULL;
        }
        if (!$is_multiple) {
            $target_entity = $field->entity;
            if (is_null($target_entity)) {
                return NULL;
            }
            $resource_identifier = static::toResourceIdentifier($target_entity);
            $resource_identifier = static::decorateResourceIdentifierWithDrupalInternalTargetId($field, $resource_identifier);
            return $resource_identifier;
        }
        else {
            $arity_counter = [];
            $relation_list = array_filter(array_map(function ($item) use (&$arity_counter) {
                $target_entity = $item->entity;
                if (is_null($target_entity)) {
                    return NULL;
                }
                $resource_identifier = static::toResourceIdentifier($target_entity);
                $resource_identifier = static::decorateResourceIdentifierWithDrupalInternalTargetId($item, $resource_identifier);
                $type = $resource_identifier['type'];
                $id = $resource_identifier['id'];
                // Start the count of identifiers sharing a single type and ID at 1.
                if (!isset($arity_counter[$type][$id])) {
                    $arity_counter[$type][$id] = 1;
                }
                else {
                    $arity_counter[$type][$id] += 1;
                }
                return $resource_identifier;
            }, iterator_to_array($field)));
            $arity_map = [];
            $relation_list = array_map(function ($identifier) use ($arity_counter, &$arity_map) {
                $type = $identifier['type'];
                $id = $identifier['id'];
                // Only add an arity value if there are two or more resource identifiers
                // with the same type and ID.
                if (($arity_counter[$type][$id] ?? 0) > 1) {
                    // Arity is indexed from 0. If the array key isn't set, 1 + (-1) = 0.
                    if (!isset($arity_map[$type][$id])) {
                        $arity_map[$type][$id] = 0;
                    }
                    else {
                        $arity_map[$type][$id] += 1;
                    }
                    $identifier['meta']['arity'] = $arity_map[$type][$id];
                }
                return $identifier;
            }, $relation_list);
            return $relation_list;
        }
    }
    
    /**
     * Adds drupal_internal__target_id to the meta of a resource identifier.
     *
     * @param \Drupal\Core\Field\FieldItemInterface|\Drupal\Core\Field\FieldItemListInterface $field
     *   The field containing the entity that is described by the
     *   resource_identifier.
     * @param array $resource_identifier
     *   A resource identifier for an entity.
     *
     * @return array
     *   A resource identifier for an entity with drupal_internal__target_id set
     *   if appropriate.
     */
    protected static function decorateResourceIdentifierWithDrupalInternalTargetId($field, array $resource_identifier) : array {
        $property_definitions = $field->getFieldDefinition()
            ->getFieldStorageDefinition()
            ->getPropertyDefinitions();
        if (!isset($property_definitions['target_id'])) {
            return $resource_identifier;
        }
        $is_data_reference_definition = $property_definitions['target_id'] instanceof DataReferenceTargetDefinition;
        if ($is_data_reference_definition) {
            // Numeric target IDs usually reference content entities, which use an
            // auto-incrementing integer ID. Non-numeric target IDs usually reference
            // config entities, which use a machine-name as an ID.
            $resource_identifier['meta']['drupal_internal__target_id'] = is_numeric($field->target_id) ? (int) $field->target_id : $field->target_id;
        }
        return $resource_identifier;
    }
    
    /**
     * Builds an array of expected related ResourceResponses, keyed by field name.
     *
     * @param array $relationship_field_names
     *   The relationship field names for which to build expected
     *   ResourceResponses.
     * @param array $request_options
     *   Request options to apply.
     * @param \Drupal\Core\Entity\EntityInterface|null $entity
     *   (optional) The entity for which to get expected related resources.
     *
     * @return \Drupal\jsonapi\ResourceResponse[]
     *   An array of expected ResourceResponses, keyed by their relationship field
     *   name.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     */
    protected function getExpectedRelatedResponses(array $relationship_field_names, array $request_options, ?EntityInterface $entity = NULL) {
        $entity = $entity ?: $this->entity;
        return array_map(function ($relationship_field_name) use ($entity, $request_options) {
            return $this->getExpectedRelatedResponse($relationship_field_name, $request_options, $entity);
        }, array_combine($relationship_field_names, $relationship_field_names));
    }
    
    /**
     * Builds an expected related ResourceResponse for the given field.
     *
     * @param string $relationship_field_name
     *   The relationship field name for which to build an expected
     *   ResourceResponse.
     * @param array $request_options
     *   Request options to apply.
     * @param \Drupal\Core\Entity\EntityInterface $entity
     *   The entity for which to get expected related resources.
     *
     * @return \Drupal\jsonapi\ResourceResponse
     *   An expected ResourceResponse.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     */
    protected function getExpectedRelatedResponse($relationship_field_name, array $request_options, EntityInterface $entity) {
        // Get the relationships responses which contain resource identifiers for
        // every related resource.
        $base_resource_identifier = static::toResourceIdentifier($entity);
        $internal_name = $this->resourceType
            ->getInternalName($relationship_field_name);
        $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()
            ->isRevisionable() ? [
            'url.query_args',
        ] : []);
        $access = $access->orIf(static::entityFieldAccess($entity, $internal_name, 'view', $this->account));
        if (!$access->isAllowed()) {
            $detail = 'The current user is not allowed to view this relationship.';
            if (!$entity->access('view') && $entity->access('view label') && $access instanceof AccessResultReasonInterface && empty($access->getReason())) {
                $access->setReason("The user only has authorization for the 'view label' operation.");
            }
            $via_link = Url::fromRoute(sprintf('jsonapi.%s.%s.related', $base_resource_identifier['type'], $relationship_field_name), [
                'entity' => $base_resource_identifier['id'],
            ]);
            $related_response = static::getAccessDeniedResponse($entity, $access, $via_link, $relationship_field_name, $detail, FALSE);
        }
        else {
            $self_link = static::getRelatedLink($base_resource_identifier, $relationship_field_name);
            $relationship_response = $this->getExpectedGetRelationshipResponse($relationship_field_name, $entity);
            $relationship_document = $relationship_response->getResponseData();
            // The relationships may be empty, in which case we shouldn't attempt to
            // fetch the individual identified resources.
            if (empty($relationship_document['data'])) {
                $cache_contexts = Cache::mergeContexts([
                    // Cache contexts for JSON:API URL query parameters.
'url.query_args',
                    // Drupal defaults.
'url.site',
                ], $this->entity
                    ->getEntityType()
                    ->isRevisionable() ? [
                    'url.query_args:resourceVersion',
                ] : []);
                $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)
                    ->addCacheTags([
                    'http_response',
                ]);
                if (isset($relationship_document['errors'])) {
                    $related_response = $relationship_response;
                }
                else {
                    $cardinality = is_null($relationship_document['data']) ? 1 : -1;
                    $related_response = (new CacheableResourceResponse(static::getEmptyCollectionResponse($cardinality, $self_link)->getResponseData()))
                        ->addCacheableDependency($cacheability);
                }
            }
            else {
                $is_to_one_relationship = static::isResourceIdentifier($relationship_document['data']);
                $resource_identifiers = $is_to_one_relationship ? [
                    $relationship_document['data'],
                ] : $relationship_document['data'];
                // Remove any relationships to 'virtual' resources.
                $resource_identifiers = array_filter($resource_identifiers, function ($resource_identifier) {
                    return $resource_identifier['id'] !== 'virtual';
                });
                if (!empty($resource_identifiers)) {
                    $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options));
                    $related_response = static::toCollectionResourceResponse($individual_responses, $self_link, !$is_to_one_relationship);
                }
                else {
                    $cardinality = $is_to_one_relationship ? 1 : -1;
                    $related_response = static::getEmptyCollectionResponse($cardinality, $self_link);
                }
            }
            $related_response->addCacheableDependency($relationship_response->getCacheableMetadata());
        }
        return $related_response;
    }
    
    /**
     * Tests POST/PATCH/DELETE for an individual resource.
     */
    public function testIndividual() : void {
        $this->doTestPostIndividual();
        $this->entity = $this->resaveEntity($this->entity, $this->account);
        $this->revokePermissions();
        $this->config('jsonapi.settings')
            ->set('read_only', TRUE)
            ->save(TRUE);
        $this->doTestPatchIndividual();
        $this->entity = $this->resaveEntity($this->entity, $this->account);
        $this->revokePermissions();
        $this->config('jsonapi.settings')
            ->set('read_only', TRUE)
            ->save(TRUE);
        $this->doTestDeleteIndividual();
    }
    
    /**
     * Tests POSTing an individual resource, plus edge cases to ensure good DX.
     */
    protected function doTestPostIndividual() : void {
        // @todo Remove this in https://www.drupal.org/node/2300677.
        if ($this->entity instanceof ConfigEntityInterface) {
            $this->markTestSkipped('POSTing config entities is not yet supported.');
        }
        // Try with all of the following request bodies.
        $not_parseable_request_body = '!{>}<';
        $parseable_valid_request_body = Json::encode($this->getPostDocument());
        $parseable_invalid_request_body_missing_type = Json::encode($this->removeResourceTypeFromDocument($this->getPostDocument()));
        if ($this->entity
            ->getEntityType()
            ->hasKey('label')) {
            $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label'));
        }
        $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep([
            'data' => [
                'id' => $this->randomMachineName(129),
            ],
        ], $this->getPostDocument()));
        $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep([
            'data' => [
                'attributes' => [
                    'field_rest_test' => $this->randomString(),
                ],
            ],
        ], $this->getPostDocument()));
        $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep([
            'data' => [
                'attributes' => [
                    'field_nonexistent' => $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.
        $url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName));
        $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('POST', $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);
        if ($this->resourceType
            ->isLocatable()) {
            $this->assertSame([
                'GET',
            ], $response->getHeader('Allow'));
        }
        else {
            $this->assertSame([
                '',
            ], $response->getHeader('Allow'));
        }
        $this->config('jsonapi.settings')
            ->set('read_only', FALSE)
            ->save(TRUE);
        // DX: 415 when no Content-Type request header.
        $response = $this->request('POST', $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('POST', $url, $request_options);
        $reason = $this->getExpectedUnauthorizedAccessMessage('POST');
        $this->assertResourceErrorResponse(403, (string) $reason, $url, $response);
        $this->setUpAuthorization('POST');
        // DX: 400 when no request body.
        $response = $this->request('POST', $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('POST', $url, $request_options);
        $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE);
        $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_missing_type;
        // DX: 400 when invalid JSON:API request body.
        $response = $this->request('POST', $url, $request_options);
        $this->assertResourceErrorResponse(400, 'Resource object must include a "type".', $url, $response, FALSE);
        if ($this->entity
            ->getEntityType()
            ->hasKey('label')) {
            $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
            // DX: 422 when invalid entity: multiple values sent for single-value field.
            $response = $this->request('POST', $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 invalid entity: UUID field too long.
        // @todo Fix this in https://www.drupal.org/project/drupal/issues/2149851.
        if ($this->entity
            ->getEntityType()
            ->hasKey('uuid')) {
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceErrorResponse(422, "IDs should be properly generated and formatted UUIDs as described in RFC 4122.", $url, $response);
        }
        $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
        // DX: 403 when entity contains field without 'edit' access.
        $response = $this->request('POST', $url, $request_options);
        $this->assertResourceErrorResponse(403, "The current user is not allowed to POST the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
        $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4;
        // DX: 422 when request document contains non-existent field.
        $response = $this->request('POST', $url, $request_options);
        $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE);
        $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('POST', $url, $request_options);
        $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $url, $response);
        $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
        // 201 for well-formed request.
        $response = $this->request('POST', $url, $request_options);
        $this->assertResourceResponse(201, FALSE, $response);
        $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
        // If the entity is stored, perform extra checks.
        if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
            $created_entity = $this->entityLoadUnchanged(static::$firstCreatedEntityId);
            $uuid = $created_entity->uuid();
            // @todo Remove line below in favor of commented line in https://www.drupal.org/project/drupal/issues/2878463.
            $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [
                'entity' => $uuid,
            ]);
            if (static::$resourceTypeIsVersionable) {
                assert($created_entity instanceof RevisionableInterface);
                $location->setOption('query', [
                    'resourceVersion' => 'id:' . $created_entity->getRevisionId(),
                ]);
            }
            
            /* $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */
            $this->assertSame([
                $location->setAbsolute()
                    ->toString(),
            ], $response->getHeader('Location'));
            // Assert that the entity was indeed created, and that the response body
            // contains the serialized created entity.
            $created_entity_document = $this->normalize($created_entity, $url);
            $decoded_response_body = $this->getDocumentFromResponse($response);
            $this->assertEquals($created_entity_document, $decoded_response_body);
            // Assert that the entity was indeed created using the POSTed values.
            foreach ($this->getPostDocument()['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, $created_entity_document['data']['attributes'][$field_name]);
                    }
                }
                else {
                    $this->assertEquals($field_normalization, $created_entity_document['data']['attributes'][$field_name]);
                }
            }
            if (isset($this->getPostDocument()['data']['relationships'])) {
                foreach ($this->getPostDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) {
                    // POSTing relationships: 'data' is required, 'links' is optional.
                    static::recursiveKsort($relationship_field_normalization);
                    static::recursiveKsort($created_entity_document['data']['relationships'][$field_name]);
                    $this->assertEquals($relationship_field_normalization, array_diff_key($created_entity_document['data']['relationships'][$field_name], [
                        'links' => TRUE,
                    ]));
                }
            }
        }
        else {
            $this->assertFalse($response->hasHeader('Location'));
        }
        // 201 for well-formed request that creates another entity.
        // If the entity is stored, delete the first created entity (in case there
        // is a uniqueness constraint).
        if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
            $this->entityStorage
                ->load(static::$firstCreatedEntityId)
                ->delete();
        }
        $response = $this->request('POST', $url, $request_options);
        $this->assertResourceResponse(201, FALSE, $response);
        $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
        if ($this->entity
            ->getEntityType()
            ->getStorageClass() !== ContentEntityNullStorage::class && $this->entity
            ->getEntityType()
            ->hasKey('uuid')) {
            $second_created_entity = $this->entityStorage
                ->load(static::$secondCreatedEntityId);
            $uuid = $second_created_entity->uuid();
            // @todo Remove line below in favor of commented line in https://www.drupal.org/project/drupal/issues/2878463.
            $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [
                'entity' => $uuid,
            ]);
            
            /* $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */
            if (static::$resourceTypeIsVersionable) {
                assert($created_entity instanceof RevisionableInterface);
                $location->setOption('query', [
                    'resourceVersion' => 'id:' . $second_created_entity->getRevisionId(),
                ]);
            }
            $this->assertSame([
                $location->setAbsolute()
                    ->toString(),
            ], $response->getHeader('Location'));
            // 500 when creating an entity with a duplicate UUID.
            $doc = $this->getModifiedEntityForPostTesting();
            $doc['data']['id'] = $uuid;
            $label_field = $this->entity
                ->getEntityType()
                ->hasKey('label') ? $this->entity
                ->getEntityType()
                ->getKey('label') : static::$labelFieldName;
            if (isset($label_field)) {
                $doc['data']['attributes'][$label_field] = [
                    [
                        'value' => $this->randomMachineName(),
                    ],
                ];
            }
            $request_options[RequestOptions::BODY] = Json::encode($doc);
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceErrorResponse(409, 'Conflict: Entity already exists.', $url, $response, FALSE);
            // 201 when successfully creating an entity with a new UUID.
            $doc = $this->getModifiedEntityForPostTesting();
            $new_uuid = \Drupal::service('uuid')->generate();
            $doc['data']['id'] = $new_uuid;
            if (isset($label_field)) {
                $doc['data']['attributes'][$label_field] = [
                    [
                        'value' => $this->randomMachineName(),
                    ],
                ];
            }
            $request_options[RequestOptions::BODY] = Json::encode($doc);
            $response = $this->request('POST', $url, $request_options);
            $this->assertResourceResponse(201, FALSE, $response);
            $entities = $this->entityStorage
                ->loadByProperties([
                $this->uuidKey => $new_uuid,
            ]);
            $new_entity = reset($entities);
            $this->assertNotNull($new_entity);
            $new_entity->delete();
        }
        else {
            $this->assertFalse($response->hasHeader('Location'));
        }
    }
    
    /**
     * Tests PATCHing an individual resource, plus edge cases to ensure good DX.
     */
    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());
    }
    
    /**
     * Tests DELETEing an individual resource, plus edge cases to ensure good DX.
     */
    protected function doTestDeleteIndividual() : void {
        // @todo Remove this in https://www.drupal.org/node/2300677.
        if ($this->entity instanceof ConfigEntityInterface) {
            $this->markTestSkipped('DELETEing config entities is not yet supported.');
        }
        // 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('DELETE', $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: 403 when unauthorized.
        $response = $this->request('DELETE', $url, $request_options);
        $reason = $this->getExpectedUnauthorizedAccessMessage('DELETE');
        $this->assertResourceErrorResponse(403, (string) $reason, $url, $response, FALSE);
        $this->setUpAuthorization('DELETE');
        // 204 for well-formed request.
        $response = $this->request('DELETE', $url, $request_options);
        $this->assertResourceResponse(204, NULL, $response);
        // DX: 404 when non-existent.
        $response = $this->request('DELETE', $url, $request_options);
        $this->assertSame(404, $response->getStatusCode());
    }
    
    /**
     * Recursively sorts an array by key.
     *
     * @param array $array
     *   An array to sort.
     */
    protected static function recursiveKsort(array &$array) {
        // First, sort the main array.
        ksort($array);
        // Then check for child arrays.
        foreach ($array as &$value) {
            if (is_array($value)) {
                static::recursiveKsort($value);
            }
        }
    }
    
    /**
     * Returns Guzzle request options for authentication.
     *
     * @return array
     *   Guzzle request options to use for authentication.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     */
    protected function getAuthenticationRequestOptions() {
        return [
            'headers' => [
                'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
            ],
        ];
    }
    
    /**
     * Clones the given entity and modifies all PATCH-protected fields.
     *
     * @param \Drupal\Core\Entity\EntityInterface $entity
     *   The entity being tested and to modify.
     *
     * @return array
     *   Contains two items:
     *   1. The modified entity object.
     *   2. The original field values, keyed by field name.
     *
     * @internal
     */
    protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
        $modified_entity = clone $entity;
        $original_values = [];
        foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) {
            $field = $modified_entity->get($field_name);
            $original_values[$field_name] = $field->getValue();
            switch ($field->getItemDefinition()
                ->getClass()) {
                case BooleanItem::class:
                    // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
                    // chance of not picking a different value.
                    $field->value = (int) $field->value === 1 ? '0' : '1';
                    break;
                case PathItem::class:
                    // PathItem::generateSampleValue() doesn't set a PID, which causes
                    // PathItem::postSave() to fail. Keep the PID (and other properties),
                    // just modify the alias.
                    $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
                    break;
                default:
                    $original_field = clone $field;
                    while ($field->equals($original_field)) {
                        $field->generateSampleItems();
                    }
                    break;
            }
        }
        return [
            $modified_entity,
            $original_values,
        ];
    }
    
    /**
     * Gets the normalized POST entity with random values for its unique fields.
     *
     * @see ::testPostIndividual
     * @see ::getPostDocument
     *
     * @return array
     *   An array structure as returned by ::getNormalizedPostEntity().
     */
    protected function getModifiedEntityForPostTesting() {
        $document = $this->getPostDocument();
        // Ensure that all the unique fields of the entity type get a new random
        // value.
        foreach (static::$uniqueFieldNames as $field_name) {
            $field_definition = $this->entity
                ->getFieldDefinition($field_name);
            $field_type_class = $field_definition->getItemDefinition()
                ->getClass();
            $document['data']['attributes'][$field_name] = $field_type_class::generateSampleValue($field_definition);
        }
        return $document;
    }
    
    /**
     * Tests sparse field sets.
     *
     * @param \Drupal\Core\Url $url
     *   The base URL with which to test includes.
     * @param array $request_options
     *   Request options to apply.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     */
    protected function doTestSparseFieldSets(Url $url, array $request_options) {
        $field_sets = $this->getSparseFieldSets();
        $expected_cacheability = new CacheableMetadata();
        foreach ($field_sets as $type => $field_set) {
            if ($type === 'all') {
                assert($this->getExpectedCacheTags($field_set) === $this->getExpectedCacheTags());
                assert($this->getExpectedCacheContexts($field_set) === $this->getExpectedCacheContexts());
            }
            $query = [
                'fields[' . static::$resourceTypeName . ']' => implode(',', $field_set),
            ];
            $expected_document = $this->getExpectedDocument();
            $expected_cacheability->setCacheTags($this->getExpectedCacheTags($field_set));
            $expected_cacheability->setCacheContexts($this->getExpectedCacheContexts($field_set));
            // This tests sparse field sets on included entities.
            if (str_starts_with($type, 'nested')) {
                $this->grantPermissionsToTestedRole([
                    'access user profiles',
                ]);
                $query['fields[user--user]'] = implode(',', $field_set);
                $query['include'] = 'uid';
                $owner = $this->entity
                    ->getOwner();
                $owner_resource = static::toResourceIdentifier($owner);
                foreach ($field_set as $field_name) {
                    $owner_resource['attributes'][$field_name] = $this->serializer
                        ->normalize($owner->get($field_name)[0]
                        ->get('value'), 'api_json');
                }
                $owner_resource['links']['self']['href'] = static::getResourceLink($owner_resource);
                $expected_document['included'] = [
                    $owner_resource,
                ];
                $expected_cacheability->addCacheableDependency($owner);
                $expected_cacheability->addCacheableDependency(static::entityAccess($owner, 'view', $this->account));
            }
            // Remove fields not in the sparse field set.
            foreach ([
                'attributes',
                'relationships',
            ] as $member) {
                if (!empty($expected_document['data'][$member])) {
                    $remaining = array_intersect_key($expected_document['data'][$member], array_flip($field_set));
                    if (empty($remaining)) {
                        unset($expected_document['data'][$member]);
                    }
                    else {
                        $expected_document['data'][$member] = $remaining;
                    }
                }
            }
            $url->setOption('query', $query);
            // 'self' link should include the 'fields' query param.
            $expected_document['links']['self']['href'] = $url->setAbsolute()
                ->toString();
            $response = $this->request('GET', $url, $request_options);
            $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', TRUE);
        }
        // Test Dynamic Page Cache HIT for a query with the same field set (unless
        // expensive cache context is present).
        $response = $this->request('GET', $url, $request_options);
        $this->assertResourceResponse(200, FALSE, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $this->generateDynamicPageCacheExpectedHeaderValue($expected_cacheability->getCacheContexts()) === 'MISS' ? 'HIT' : 'UNCACHEABLE (poor cacheability)');
    }
    
    /**
     * Tests included resources.
     *
     * @param \Drupal\Core\Url $url
     *   The base URL with which to test includes.
     * @param array $request_options
     *   Request options to apply.
     *
     * @see \GuzzleHttp\ClientInterface::request()
     */
    protected function doTestIncluded(Url $url, array $request_options) {
        $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
        // If there are no relationship fields, we can't include anything.
        if (empty($relationship_field_names)) {
            return;
        }
        $field_sets = [
            'empty' => [],
            'all' => $relationship_field_names,
        ];
        if (count($relationship_field_names) > 1) {
            $about_half_the_fields = (int) floor(count($relationship_field_names) / 2);
            $field_sets['some'] = array_slice($relationship_field_names, $about_half_the_fields);
            $nested_includes = $this->getNestedIncludePaths();
            if (!empty($nested_includes) && !in_array($nested_includes, $field_sets)) {
                $field_sets['nested'] = $nested_includes;
            }
        }
        foreach ($field_sets as $included_paths) {
            $this->grantIncludedPermissions($included_paths);
            $query = [
                'include' => implode(',', $included_paths),
            ];
            $url->setOption('query', $query);
            $actual_response = $this->request('GET', $url, $request_options);
            $expected_response = $this->getExpectedIncludedResourceResponse($included_paths, $request_options);
            $expected_document = $expected_response->getResponseData();
            // Dynamic Page Cache miss because cache should vary based on the
            // 'include' query param.
            $expected_cacheability = $expected_response->getCacheableMetadata();
            $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, $this->generateDynamicPageCacheExpectedHeaderValue($this->getExpectedCacheContexts(), $expected_cacheability->getCacheMaxAge()));
        }
    }
    
    /**
     * Tests individual and collection revisions.
     */
    public function testRevisions() : void {
        if (!$this->entity
            ->getEntityType()
            ->isRevisionable() || !$this->entity instanceof FieldableEntityInterface) {
            return;
        }
        assert($this->entity instanceof RevisionableInterface);
        // Add a field to modify in order to test revisions.
        FieldStorageConfig::create([
            'entity_type' => static::$entityTypeId,
            'field_name' => 'field_revisionable_number',
            'type' => 'integer',
        ])->setCardinality(1)
            ->save();
        FieldConfig::create([
            'entity_type' => static::$entityTypeId,
            'field_name' => 'field_revisionable_number',
            'bundle' => $this->entity
                ->bundle(),
        ])
            ->setLabel('Revisionable text field')
            ->setTranslatable(FALSE)
            ->save();
        // Reload entity so that it has the new field.
        $entity = $this->entityLoadUnchanged($this->entity
            ->id());
        // Set up test data.
        
        /** @var \Drupal\Core\Entity\FieldableEntityInterface $entity */
        $entity->set('field_revisionable_number', 42);
        $entity->save();
        $original_revision_id = (int) $entity->getRevisionId();
        $entity->set('field_revisionable_number', 99);
        $entity->setNewRevision();
        $entity->save();
        $latest_revision_id = (int) $entity->getRevisionId();
        // @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(),
        ])
            ->setAbsolute();
        // $url = $this->entity->toUrl('jsonapi');
        $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute();
        $relationship_url = Url::fromRoute(sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), [
            'entity' => $this->entity
                ->uuid(),
        ])
            ->setAbsolute();
        $related_url = Url::fromRoute(sprintf('jsonapi.%s.%s.related', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), [
            'entity' => $this->entity
                ->uuid(),
        ])
            ->setAbsolute();
        $original_revision_id_url = clone $url;
        $original_revision_id_url->setOption('query', [
            'resourceVersion' => "id:{$original_revision_id}",
        ]);
        $original_revision_id_relationship_url = clone $relationship_url;
        $original_revision_id_relationship_url->setOption('query', [
            'resourceVersion' => "id:{$original_revision_id}",
        ]);
        $original_revision_id_related_url = clone $related_url;
        $original_revision_id_related_url->setOption('query', [
            'resourceVersion' => "id:{$original_revision_id}",
        ]);
        $latest_revision_id_url = clone $url;
        $latest_revision_id_url->setOption('query', [
            'resourceVersion' => "id:{$latest_revision_id}",
        ]);
        $latest_revision_id_relationship_url = clone $relationship_url;
        $latest_revision_id_relationship_url->setOption('query', [
            'resourceVersion' => "id:{$latest_revision_id}",
        ]);
        $latest_revision_id_related_url = clone $related_url;
        $latest_revision_id_related_url->setOption('query', [
            'resourceVersion' => "id:{$latest_revision_id}",
        ]);
        $rel_latest_version_url = clone $url;
        $rel_latest_version_url->setOption('query', [
            'resourceVersion' => 'rel:latest-version',
        ]);
        $rel_latest_version_relationship_url = clone $relationship_url;
        $rel_latest_version_relationship_url->setOption('query', [
            'resourceVersion' => 'rel:latest-version',
        ]);
        $rel_latest_version_related_url = clone $related_url;
        $rel_latest_version_related_url->setOption('query', [
            'resourceVersion' => 'rel:latest-version',
        ]);
        $rel_latest_version_collection_url = clone $collection_url;
        $rel_latest_version_collection_url->setOption('query', [
            'resourceVersion' => 'rel:latest-version',
        ]);
        $rel_working_copy_url = clone $url;
        $rel_working_copy_url->setOption('query', [
            'resourceVersion' => 'rel:working-copy',
        ]);
        $rel_working_copy_relationship_url = clone $relationship_url;
        $rel_working_copy_relationship_url->setOption('query', [
            'resourceVersion' => 'rel:working-copy',
        ]);
        $rel_working_copy_related_url = clone $related_url;
        $rel_working_copy_related_url->setOption('query', [
            'resourceVersion' => 'rel:working-copy',
        ]);
        $rel_working_copy_collection_url = clone $collection_url;
        $rel_working_copy_collection_url->setOption('query', [
            'resourceVersion' => 'rel:working-copy',
        ]);
        $rel_invalid_collection_url = clone $collection_url;
        $rel_invalid_collection_url->setOption('query', [
            'resourceVersion' => 'rel:invalid',
        ]);
        $revision_id_key = 'drupal_internal__' . $this->entity
            ->getEntityType()
            ->getKey('revision');
        $published_key = $this->entity
            ->getEntityType()
            ->getKey('published');
        $revision_translation_affected_key = $this->entity
            ->getEntityType()
            ->getKey('revision_translation_affected');
        $amend_relationship_urls = function (array &$document, $revision_id) {
            if (!empty($document['data']['relationships'])) {
                foreach ($document['data']['relationships'] as &$relationship) {
                    $pattern = '/resourceVersion=id%3A\\d/';
                    $replacement = 'resourceVersion=' . urlencode("id:{$revision_id}");
                    $relationship['links']['self']['href'] = preg_replace($pattern, $replacement, $relationship['links']['self']['href']);
                    $relationship['links']['related']['href'] = preg_replace($pattern, $replacement, $relationship['links']['related']['href']);
                }
            }
        };
        $request_options = [];
        $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
        $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
        // Ensure 403 forbidden on typical GET.
        $actual_response = $this->request('GET', $url, $request_options);
        $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
        $result = $entity->access('view', $this->account, TRUE);
        $detail = 'The current user is not allowed to GET the selected resource.';
        if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) {
            $detail .= ' ' . $reason;
        }
        $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, TRUE);
        // Ensure that targeting a revision does not bypass access.
        $actual_response = $this->request('GET', $original_revision_id_url, $request_options);
        $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
        $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.';
        if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) {
            $detail .= ' ' . $reason;
        }
        $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, TRUE);
        $this->setUpRevisionAuthorization('GET');
        // Ensure that the URL without a `resourceVersion` query parameter returns
        // the default revision. This is always the latest revision when
        // content_moderation is not installed.
        $actual_response = $this->request('GET', $url, $request_options);
        $expected_document = $this->alterExpectedDocumentForRevision($this->getExpectedDocument());
        // The resource object should always links to the specific revision it
        // represents.
        $expected_document['data']['links']['self']['href'] = $latest_revision_id_url->setAbsolute()
            ->toString();
        $amend_relationship_urls($expected_document, $latest_revision_id);
        // Resource objects always link to their specific revision by revision ID.
        $expected_document['data']['attributes'][$revision_id_key] = $latest_revision_id;
        $expected_document['data']['attributes']['field_revisionable_number'] = 99;
        $expected_cache_tags = $this->getExpectedCacheTags();
        $expected_cache_contexts = $this->getExpectedCacheContexts();
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
        // Fetch the same revision using its revision ID.
        $actual_response = $this->request('GET', $latest_revision_id_url, $request_options);
        // The top-level document object's `self` link should always link to the
        // request URL.
        $expected_document['links']['self']['href'] = $latest_revision_id_url->setAbsolute()
            ->toString();
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
        // Ensure dynamic cache HIT on second request when using a version
        // negotiator.
        $actual_response = $this->request('GET', $latest_revision_id_url, $request_options);
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, $this->generateDynamicPageCacheExpectedHeaderValue($expected_cache_contexts) === 'MISS' ? 'HIT' : 'UNCACHEABLE');
        // Fetch the same revision using the `latest-version` link relation type
        // negotiator. Without content_moderation, this is always the most recent
        // revision.
        $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
        $expected_document['links']['self']['href'] = $rel_latest_version_url->setAbsolute()
            ->toString();
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
        // Fetch the same revision using the `working-copy` link relation type
        // negotiator. Without content_moderation, this is always the most recent
        // revision.
        $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
        $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()
            ->toString();
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
        // Fetch the prior revision.
        $actual_response = $this->request('GET', $original_revision_id_url, $request_options);
        $expected_document['data']['attributes'][$revision_id_key] = $original_revision_id;
        $expected_document['data']['attributes']['field_revisionable_number'] = 42;
        $expected_document['links']['self']['href'] = $original_revision_id_url->setAbsolute()
            ->toString();
        // The resource object should always links to the specific revision it
        // represents.
        $expected_document['data']['links']['self']['href'] = $original_revision_id_url->setAbsolute()
            ->toString();
        $amend_relationship_urls($expected_document, $original_revision_id);
        // When the resource object is not the latest version or the working copy,
        // a link should be provided that links to those versions. Therefore, the
        // presence or absence of these links communicates the state of the resource
        // object.
        $expected_document['data']['links']['latest-version']['href'] = $rel_latest_version_url->setAbsolute()
            ->toString();
        $expected_document['data']['links']['working-copy']['href'] = $rel_working_copy_url->setAbsolute()
            ->toString();
        $this->assertResourceResponse(200, $expected_document, $actual_response, Cache::mergeTags($expected_cache_tags, $this->getExtraRevisionCacheTags()), $expected_cache_contexts, NULL, TRUE);
        // Install content_moderation module.
        $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;
        }
        // Set up an editorial workflow.
        $workflow = $this->createEditorialWorkflow();
        $workflow->getTypePlugin()
            ->addEntityTypeAndBundle(static::$entityTypeId, $this->entity
            ->bundle());
        $workflow->save();
        $this->grantPermissionsToTestedRole([
            'use editorial transition publish',
        ]);
        // Ensure the test entity has content_moderation fields attached to it.
        
        /** @var \Drupal\Core\Entity\FieldableEntityInterface|\Drupal\Core\Entity\TranslatableRevisionableInterface $entity */
        $entity = $this->entityStorage
            ->load($entity->id());
        // Set the published moderation state on the test entity.
        $entity->set('moderation_state', 'published');
        $entity->setNewRevision();
        $entity->save();
        $default_revision_id = (int) $entity->getRevisionId();
        // Fetch the published revision by using the `rel` version negotiator and
        // the `latest-version` version argument. With content_moderation, this is
        // now the most recent revision where the moderation state was the 'default'
        // one.
        $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
        $expected_document['data']['attributes'][$revision_id_key] = $default_revision_id;
        $expected_document['data']['attributes']['moderation_state'] = 'published';
        $expected_document['data']['attributes'][$published_key] = TRUE;
        $expected_document['data']['attributes']['field_revisionable_number'] = 99;
        $expected_document['links']['self']['href'] = $rel_latest_version_url->toString();
        $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected();
        // The resource object now must link to the new revision.
        $default_revision_id_url = clone $url;
        $default_revision_id_url = $default_revision_id_url->setOption('query', [
            'resourceVersion' => "id:{$default_revision_id}",
        ]);
        $expected_document['data']['links']['self']['href'] = $default_revision_id_url->setAbsolute()
            ->toString();
        $amend_relationship_urls($expected_document, $default_revision_id);
        // Since the requested version is the latest version and working copy, there
        // should be no links.
        unset($expected_document['data']['links']['latest-version']);
        unset($expected_document['data']['links']['working-copy']);
        $expected_document = $this->alterExpectedDocumentForRevision($expected_document);
        $expected_cache_tags = array_unique([
            $expected_cache_tags,
            $workflow->getCacheTags(),
        ]);
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
        // Fetch the collection URL using the `latest-version` version argument.
        $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options);
        $expected_response = $this->getExpectedCollectionResponse([
            $entity,
        ], $rel_latest_version_collection_url->toString(), $request_options);
        $expected_collection_document = $expected_response->getResponseData();
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, TRUE);
        // Fetch the published revision by using the `working-copy` version
        // argument. With content_moderation, this is always the most recent
        // revision regardless of moderation state.
        $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
        $expected_document['links']['self']['href'] = $rel_working_copy_url->toString();
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
        // Fetch the collection URL using the `working-copy` version argument.
        $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
        $expected_collection_document['links']['self']['href'] = $rel_working_copy_collection_url->toString();
        $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, TRUE);
        // @todo Remove the next assertion when Drupal core supports entity query access control on revisions.
        $rel_working_copy_collection_url_filtered = clone $rel_working_copy_collection_url;
        $rel_working_copy_collection_url_filtered->setOption('query', [
            'filter[foo]' => 'bar',
        ] + $rel_working_copy_collection_url->getOption('query'));
        $actual_response = $this->request('GET', $rel_working_copy_collection_url_filtered, $request_options);
        $filtered_collection_expected_cache_contexts = [
            'url.path',
            'url.query_args',
            'url.site',
        ];
        $this->assertResourceErrorResponse(501, 'JSON:API does not support filtering on revisions other than the latest version because a secure Drupal core API does not yet exist to do so.', $rel_working_copy_collection_url_filtered, $actual_response, FALSE, [
            'http_response',
        ], $filtered_collection_expected_cache_contexts);
        // Fetch the collection URL using an invalid version identifier.
        $actual_response = $this->request('GET', $rel_invalid_collection_url, $request_options);
        $invalid_version_expected_cache_contexts = [
            'url.path',
            'url.query_args',
            'url.site',
        ];
        $this->assertResourceErrorResponse(400, 'Collection resources only support the following resource version identifiers: rel:latest-version, rel:working-copy', $rel_invalid_collection_url, $actual_response, FALSE, [
            '4xx-response',
            'http_response',
        ], $invalid_version_expected_cache_contexts);
        // Move the entity to its draft moderation state.
        $entity->set('field_revisionable_number', 42);
        // Change a relationship field so revisions can be tested on related and
        // relationship routes.
        $new_user = $this->createUser();
        $new_user->save();
        $entity->set('field_jsonapi_test_entity_ref', [
            'target_id' => $new_user->id(),
        ]);
        $entity->set('moderation_state', 'draft');
        $entity->setNewRevision();
        $entity->save();
        $forward_revision_id = (int) $entity->getRevisionId();
        // The `latest-version` link should *still* reference the same revision
        // since a draft is not a default revision.
        $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
        $expected_document['links']['self']['href'] = $rel_latest_version_url->toString();
        // Since the latest version is no longer also the working copy, a
        // `working-copy` link is required to indicate that there is a forward
        // revision available.
        $expected_document['data']['links']['working-copy']['href'] = $rel_working_copy_url->setAbsolute()
            ->toString();
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
        // And the same should be true for collections.
        $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options);
        $expected_collection_document['data'][0] = $expected_document['data'];
        $expected_collection_document['links']['self']['href'] = $rel_latest_version_collection_url->toString();
        $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, TRUE);
        // Ensure that the `latest-version` response is same as the default link,
        // aside from the document's `self` link.
        $actual_response = $this->request('GET', $url, $request_options);
        $expected_document['links']['self']['href'] = $url->toString();
        $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
        // And the same should be true for collections.
        $actual_response = $this->request('GET', $collection_url, $request_options);
        $expected_collection_document['links']['self']['href'] = $collection_url->toString();
        $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, TRUE);
        // Now, the `working-copy` link should reference the draft revision. This
        // is significant because without content_moderation, the two responses
        // would still been the same.
        //
        // Access is checked before any special permissions are granted. This
        // asserts a 403 forbidden if the user is not allowed to see unpublished
        // content.
        $result = $entity->access('view', $this->account, TRUE);
        if (!$result->isAllowed()) {
            $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
            $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
            $expected_cache_tags = Cache::mergeTags($expected_cacheability->getCacheTags(), $entity->getCacheTags());
            $expected_cache_contexts = $expected_cacheability->getCacheContexts();
            $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.';
            $message = $result instanceof AccessResultReasonInterface ? trim($detail . ' ' . $result->getReason()) : $detail;
            $this->assertResourceErrorResponse(403, $message, $url, $actual_response, '/data', $expected_cache_tags, $expected_cache_contexts, NULL, TRUE);
            // On the collection URL, we should expect to see the draft omitted from
            // the collection.
            $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
            $expected_response = static::getExpectedCollectionResponse([
                $entity,
            ], $rel_working_copy_collection_url->toString(), $request_options);
            $expected_collection_document = $expected_response->getResponseData();
            $expected_collection_document['data'] = [];
            $expected_cacheability = $expected_response->getCacheableMetadata();
            $access_denied_response = static::getAccessDeniedResponse($entity, $result, $url, NULL, $detail)->getResponseData();
            static::addOmittedObject($expected_collection_document, static::errorsToOmittedObject($access_denied_response['errors']));
            $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), NULL, TRUE);
        }
        // Since additional permissions are required to see 'draft' entities,
        // grant those permissions.
        $this->grantPermissionsToTestedRole($this->getEditorialPermissions());
        // Now, the `working-copy` link should be latest revision and be accessible.
        $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
        $expected_document['data']['attributes'][$revision_id_key] = $forward_revision_id;
        $expected_document['data']['attributes']['moderation_state'] = 'draft';
        $expected_document['data']['attributes'][$published_key] = FALSE;
        $expected_document['data']['attributes']['field_revisionable_number'] = 42;
        $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()
            ->toString();
        $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected();
        // The resource object now must link to the forward revision.
        $forward_revision_id_url = clone $url;
        $forward_revision_id_url = $forward_revision_id_url->setOption('query', [
            'resourceVersion' => "id:{$forward_revision_id}",
        ]);
        $expected_document['data']['links']['self']['href'] = $forward_revision_id_url->setAbsolute()
            ->toString();
        $amend_relationship_urls($expected_document, $forward_revision_id);
        // Since the working copy is not the default revision. A `latest-version`
        // link is required to indicate that the requested version is not the
        // default revision.
        unset($expected_document['data']['links']['working-copy']);
        $expected_document['data']['links']['latest-version']['href'] = $rel_latest_version_url->setAbsolute()
            ->toString();
        $expected_cache_tags = $this->getExpectedCacheTags();
        $expected_cache_contexts = $this->getExpectedCacheContexts();
        $expected_cache_tags = array_unique([
            $expected_cache_tags,
            $workflow->getCacheTags(),
        ]);
        $this->assertResourceResponse(200, $expected_document, $actual_response, Cache::mergeTags($expected_cache_tags, $this->getExtraRevisionCacheTags()), $expected_cache_contexts, NULL, TRUE);
        // And the collection response should also have the latest revision.
        $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
        $expected_response = static::getExpectedCollectionResponse([
            $entity,
        ], $rel_working_copy_collection_url->toString(), $request_options);
        $expected_collection_document = $expected_response->getResponseData();
        $expected_collection_document['data'] = [
            $expected_document['data'],
        ];
        $expected_cacheability = $expected_response->getCacheableMetadata();
        $this->assertResourceResponse(200, $expected_collection_document, $actual_response, Cache::mergeTags($expected_cacheability->getCacheTags(), $this->getExtraRevisionCacheTags()), $expected_cacheability->getCacheContexts(), NULL, TRUE);
        // Test relationship responses.
        // Fetch the prior revision's relationship URL.
        $test_relationship_urls = [
            'canonical' => [
                NULL,
                $relationship_url,
                $related_url,
            ],
            'original' => [
                $original_revision_id,
                $original_revision_id_relationship_url,
                $original_revision_id_related_url,
            ],
            'latest' => [
                $latest_revision_id,
                $latest_revision_id_relationship_url,
                $latest_revision_id_related_url,
            ],
            'default' => [
                $default_revision_id,
                $rel_latest_version_relationship_url,
                $rel_latest_version_related_url,
            ],
            'forward' => [
                $forward_revision_id,
                $rel_working_copy_relationship_url,
                $rel_working_copy_related_url,
            ],
        ];
        $default_revision_types = [
            'canonical',
            'default',
        ];
        foreach ($test_relationship_urls as $relationship_type => $revision_case) {
            [
                $revision_id,
                $relationship_url,
                $related_url,
            ] = $revision_case;
            // Load the revision that will be requested.
            $this->entityStorage
                ->resetCache([
                $entity->id(),
            ]);
            if ($revision_id === NULL) {
                $revision = $this->entityStorage
                    ->load($entity->id());
            }
            else {
                
                /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
                $storage = $this->entityStorage;
                $revision = $storage->loadRevision($revision_id);
            }
            // Request the relationship resource without access to the relationship
            // field.
            $actual_response = $this->request('GET', $relationship_url, $request_options);
            $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision);
            $expected_document = $expected_response->getResponseData();
            $expected_cacheability = $expected_response->getCacheableMetadata();
            $expected_document['errors'][0]['links']['via']['href'] = $relationship_url->toString();
            // Only add node type check tags for non-default revisions.
            $expected_cache_tags = !in_array($relationship_type, $default_revision_types, TRUE) ? Cache::mergeTags($expected_cacheability->getCacheTags(), $this->getExtraRevisionCacheTags()) : $expected_cacheability->getCacheTags();
            // @todo Remove this in https://www.drupal.org/project/drupal/issues/3451483.
            $actual_response = $actual_response->withoutHeader('X-Drupal-Dynamic-Cache');
            $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cache_tags, $expected_cacheability->getCacheContexts());
            // Request the related route.
            $actual_response = $this->request('GET', $related_url, $request_options);
            // @todo Refactor self::getExpectedRelatedResponses() into a function which returns a single response.
            $expected_response = $this->getExpectedRelatedResponses([
                'field_jsonapi_test_entity_ref',
            ], $request_options, $revision)['field_jsonapi_test_entity_ref'];
            $expected_document = $expected_response->getResponseData();
            $expected_cacheability = $expected_response->getCacheableMetadata();
            $expected_document['errors'][0]['links']['via']['href'] = $related_url->toString();
            // @todo Remove this in https://www.drupal.org/project/drupal/issues/3451483.
            $actual_response = $actual_response->withoutHeader('X-Drupal-Dynamic-Cache');
            $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cache_tags, $expected_cacheability->getCacheContexts());
        }
        $this->grantPermissionsToTestedRole([
            'field_jsonapi_test_entity_ref view access',
        ]);
        foreach ($test_relationship_urls as $relationship_type => $revision_case) {
            [
                $revision_id,
                $relationship_url,
                $related_url,
            ] = $revision_case;
            // Load the revision that will be requested.
            $this->entityStorage
                ->resetCache([
                $entity->id(),
            ]);
            if ($revision_id === NULL) {
                $revision = $this->entityStorage
                    ->load($entity->id());
            }
            else {
                
                /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
                $storage = $this->entityStorage;
                $revision = $storage->loadRevision($revision_id);
            }
            // Request the relationship resource after granting access to the
            // relationship field.
            $actual_response = $this->request('GET', $relationship_url, $request_options);
            $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision);
            $expected_document = $expected_response->getResponseData();
            $expected_document['links']['self']['href'] = $relationship_url->setAbsolute()
                ->toString();
            $expected_cacheability = $expected_response->getCacheableMetadata();
            // Only add node type check tags for non-default revisions.
            $expected_cache_tags = !in_array($relationship_type, $default_revision_types, TRUE) ? Cache::mergeTags($expected_cacheability->getCacheTags(), $this->getExtraRevisionCacheTags()) : $expected_cacheability->getCacheTags();
            $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cacheability->getCacheContexts(), NULL, TRUE);
            // Request the related route.
            $actual_response = $this->request('GET', $related_url, $request_options);
            $expected_response = $this->getExpectedRelatedResponse('field_jsonapi_test_entity_ref', $request_options, $revision);
            $expected_document = $expected_response->getResponseData();
            $expected_cacheability = $expected_response->getCacheableMetadata();
            $expected_document['links']['self']['href'] = $related_url->toString();
            $expected_cache_tags = !in_array($relationship_type, $default_revision_types, TRUE) ? Cache::mergeTags($expected_cacheability->getCacheTags(), $this->getExtraRevisionCacheTags()) : $expected_cacheability->getCacheTags();
            $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cacheability->getCacheContexts(), NULL, TRUE);
        }
        $this->config('jsonapi.settings')
            ->set('read_only', FALSE)
            ->save(TRUE);
        // Ensures that PATCH and DELETE on individual resources with a
        // `resourceVersion` query parameter is not supported.
        $individual_urls = [
            $original_revision_id_url,
            $latest_revision_id_url,
            $rel_latest_version_url,
            $rel_working_copy_url,
        ];
        $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
        foreach ($individual_urls as $url) {
            foreach ([
                'PATCH',
                'DELETE',
            ] as $method) {
                $actual_response = $this->request($method, $url, $request_options);
                $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response);
            }
        }
        // Ensures that PATCH, POST and DELETE on relationship resources with a
        // `resourceVersion` query parameter is not supported.
        $relationship_urls = [
            $original_revision_id_relationship_url,
            $latest_revision_id_relationship_url,
            $rel_latest_version_relationship_url,
            $rel_working_copy_relationship_url,
        ];
        foreach ($relationship_urls as $url) {
            foreach ([
                'PATCH',
                'POST',
                'DELETE',
            ] as $method) {
                $actual_response = $this->request($method, $url, $request_options);
                $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response);
            }
        }
        // Ensures that POST on collection resources with a `resourceVersion` query
        // parameter is not supported.
        $collection_urls = [
            $rel_latest_version_collection_url,
            $rel_working_copy_collection_url,
        ];
        foreach ($collection_urls as $url) {
            foreach ([
                'POST',
            ] as $method) {
                $actual_response = $this->request($method, $url, $request_options);
                $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response);
            }
        }
    }
    
    /**
     * Decorates the expected response with included data and cache metadata.
     *
     * This adds the expected includes to the expected document and also builds
     * the expected cacheability for those includes. It does so based of responses
     * from the related routes for individual relationships.
     *
     * @param \Drupal\jsonapi\CacheableResourceResponse $expected_response
     *   The expected ResourceResponse.
     * @param \Drupal\jsonapi\ResourceResponse[] $related_responses
     *   The related ResourceResponses, keyed by relationship field names.
     *
     * @return \Drupal\jsonapi\CacheableResourceResponse
     *   The decorated ResourceResponse.
     */
    protected static function decorateExpectedResponseForIncludedFields(CacheableResourceResponse $expected_response, array $related_responses) {
        $expected_document = $expected_response->getResponseData();
        $expected_cacheability = $expected_response->getCacheableMetadata();
        foreach ($related_responses as $related_response) {
            $related_document = $related_response->getResponseData();
            $expected_cacheability->addCacheableDependency($related_response->getCacheableMetadata());
            $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), [
                '4xx-response',
            ])));
            // If any of the related response documents had omitted items or errors,
            // we should later expect the document to have omitted items as well.
            if (!empty($related_document['errors'])) {
                static::addOmittedObject($expected_document, static::errorsToOmittedObject($related_document['errors']));
            }
            if (!empty($related_document['meta']['omitted'])) {
                static::addOmittedObject($expected_document, $related_document['meta']['omitted']);
            }
            if (isset($related_document['data'])) {
                $related_data = $related_document['data'];
                $related_resources = static::isResourceIdentifier($related_data) ? [
                    $related_data,
                ] : $related_data;
                foreach ($related_resources as $related_resource) {
                    if (empty($expected_document['included']) || !static::collectionHasResourceIdentifier($related_resource, $expected_document['included'])) {
                        $expected_document['included'][] = $related_resource;
                    }
                }
            }
        }
        return (new CacheableResourceResponse($expected_document))->addCacheableDependency($expected_cacheability);
    }
    
    /**
     * Gets the expected individual ResourceResponse for GET.
     *
     * @return \Drupal\jsonapi\CacheableResourceResponse
     *   The expected individual ResourceResponse.
     */
    protected function getExpectedGetIndividualResourceResponse($status_code = 200) {
        $resource_response = new CacheableResourceResponse($this->getExpectedDocument(), $status_code);
        $cacheability = new CacheableMetadata();
        $cacheability->setCacheContexts($this->getExpectedCacheContexts());
        $cacheability->setCacheTags($this->getExpectedCacheTags());
        return $resource_response->addCacheableDependency($cacheability);
    }
    
    /**
     * Returns an array of sparse fields sets to test.
     *
     * @return array
     *   An array of sparse field sets (an array of field names), keyed by a label
     *   for the field set.
     */
    protected function getSparseFieldSets() {
        $field_names = array_keys($this->entity
            ->toArray());
        $field_sets = [
            'empty' => [],
            'some' => array_slice($field_names, (int) floor(count($field_names) / 2)),
            'all' => $field_names,
        ];
        if ($this->entity instanceof EntityOwnerInterface) {
            $field_sets['nested_empty_fieldset'] = $field_sets['empty'];
            $field_sets['nested_fieldset_with_owner_fieldset'] = [
                'name',
                'created',
            ];
        }
        return $field_sets;
    }
    
    /**
     * Gets a list of public relationship names for the resource type under test.
     *
     * @param \Drupal\Core\Entity\EntityInterface|null $entity
     *   (optional) The entity for which to get relationship field names.
     *
     * @return array
     *   An array of relationship field names.
     */
    protected function getRelationshipFieldNames(?EntityInterface $entity = NULL) {
        $entity = $entity ?: $this->entity;
        // Only content entity types can have relationships.
        $fields = $entity instanceof ContentEntityInterface ? iterator_to_array($entity) : [];
        return array_reduce($fields, function ($field_names, $field) {
            
            /** @var \Drupal\Core\Field\FieldItemListInterface $field */
            if (static::isReferenceFieldDefinition($field->getFieldDefinition())) {
                $field_names[] = $this->resourceType
                    ->getPublicName($field->getName());
            }
            return $field_names;
        }, []);
    }
    
    /**
     * Authorize the user under test with additional permissions to view includes.
     *
     * @return array
     *   An array of special permissions to be granted for certain relationship
     *   paths where the keys are relationships paths and values are an array of
     *   permissions.
     */
    protected static function getIncludePermissions() {
        return [];
    }
    
    /**
     * Gets an array of permissions required to view and update any tested entity.
     *
     * @return string[]
     *   An array of permission names.
     */
    protected function getEditorialPermissions() {
        return [
            'view latest version',
            "view any unpublished content",
        ];
    }
    
    /**
     * Checks access for the given operation on the given entity.
     *
     * @param \Drupal\Core\Entity\EntityInterface $entity
     *   The entity for which to check field access.
     * @param string $operation
     *   The operation for which to check access.
     * @param \Drupal\Core\Session\AccountInterface $account
     *   The account for which to check access.
     *
     * @return \Drupal\Core\Access\AccessResultInterface
     *   The AccessResult.
     */
    protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
        // The default entity access control handler assumes that permissions do not
        // change during the lifetime of a request and caches access results.
        // However, we're changing permissions during a test run and need fresh
        // results, so reset the cache.
        \Drupal::entityTypeManager()->getAccessControlHandler($entity->getEntityTypeId())
            ->resetCache();
        return $entity->access($operation, $account, TRUE);
    }
    
    /**
     * Checks access for the given field operation on the given entity.
     *
     * @param \Drupal\Core\Entity\EntityInterface $entity
     *   The entity for which to check field access.
     * @param string $field_name
     *   The field for which to check access.
     * @param string $operation
     *   The operation for which to check access.
     * @param \Drupal\Core\Session\AccountInterface $account
     *   The account for which to check access.
     *
     * @return \Drupal\Core\Access\AccessResultInterface
     *   The AccessResult.
     */
    protected static function entityFieldAccess(EntityInterface $entity, $field_name, $operation, AccountInterface $account) {
        $entity_access = static::entityAccess($entity, $operation === 'edit' ? 'update' : 'view', $account);
        $field_access = $entity->{$field_name}
            ->access($operation, $account, TRUE);
        return $entity_access->andIf($field_access);
    }
    
    /**
     * Gets an array of all nested include paths to be tested.
     *
     * @param int $depth
     *   (optional) The maximum depth to which included paths should be nested.
     *
     * @return array
     *   An array of nested include paths.
     */
    protected function getNestedIncludePaths($depth = 3) {
        $get_nested_relationship_field_names = function (EntityInterface $entity, $depth, $path = "") use (&$get_nested_relationship_field_names) {
            $relationship_field_names = $this->getRelationshipFieldNames($entity);
            if ($depth > 0) {
                $paths = [];
                foreach ($relationship_field_names as $field_name) {
                    $next = $path ? "{$path}.{$field_name}" : $field_name;
                    $internal_field_name = $this->resourceType
                        ->getInternalName($field_name);
                    if ($target_entity = $entity->{$internal_field_name}->entity) {
                        $deep = $get_nested_relationship_field_names($target_entity, $depth - 1, $next);
                        $paths = array_merge($paths, $deep);
                    }
                    else {
                        $paths[] = $next;
                    }
                }
                return $paths;
            }
            return array_map(function ($target_name) use ($path) {
                return "{$path}.{$target_name}";
            }, $relationship_field_names);
        };
        return $get_nested_relationship_field_names($this->entity, $depth);
    }
    
    /**
     * Determines if a given field definition is a reference field.
     *
     * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
     *   The field definition to inspect.
     *
     * @return bool
     *   TRUE if the field definition is found to be a reference field. FALSE
     *   otherwise.
     */
    protected static function isReferenceFieldDefinition(FieldDefinitionInterface $field_definition) : bool {
        
        /** @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */
        $item_definition = $field_definition->getItemDefinition();
        $main_property = $item_definition->getMainPropertyName();
        $property_definition = $item_definition->getPropertyDefinition($main_property);
        return $property_definition instanceof DataReferenceTargetDefinition;
    }
    
    /**
     * Grants authorization to view includes.
     *
     * @param string[] $include_paths
     *   An array of include paths for which to grant access.
     */
    protected function grantIncludedPermissions(array $include_paths = []) {
        $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($include_paths));
        $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', []));
        // Always grant access to 'view' the test entity reference field.
        $flattened_permissions[] = 'field_jsonapi_test_entity_ref view access';
        $this->grantPermissionsToTestedRole($flattened_permissions);
    }
    
    /**
     * Loads an entity in the test container, ignoring the static cache.
     *
     * @param int $id
     *   The entity ID.
     *
     * @return \Drupal\Core\Entity\EntityInterface|null
     *   The loaded entity.
     *
     * @todo Remove this after https://www.drupal.org/project/drupal/issues/3038706 lands.
     */
    protected function entityLoadUnchanged($id) {
        $this->entityStorage
            ->resetCache();
        return $this->entityStorage
            ->loadUnchanged($id);
    }
    
    /**
     * Alters the expected JSON:API document for revisions.
     *
     * Default revision tests assume a non-privileged user is performing the GET
     * request and as such the expected document may not include the revision log
     * or other fields that require elevated permissions. This method is an
     * extension point where child classes can modify the expected document to
     * take into account these changes.
     *
     * @param array $expected_document
     *   Expected document for the default revision.
     *
     * @return array[]
     *   Modified document for a revision or user with access to edit a revision
     *   AND/OR view revision information.
     */
    protected function alterExpectedDocumentForRevision(array $expected_document) : array {
        $entity_type = $this->entity
            ->getEntityType();
        if ($entity_type instanceof ContentEntityTypeInterface && ($field_name = $entity_type->getRevisionMetadataKey('revision_log_message'))) {
            // The default entity access control handler assumes that permissions do not
            // change during the lifetime of a request and caches access results.
            // However, we're changing permissions during a test run and need fresh
            // results, so reset the cache.
            \Drupal::entityTypeManager()->getAccessControlHandler($this->entity
                ->getEntityTypeId())
                ->resetCache();
            $revisionLogAccess = $this->entity
                ->access('view revision', $this->account, TRUE)
                ->orIf($this->entity
                ->access('update', $this->account, TRUE));
            if ($revisionLogAccess->isAllowed()) {
                $expected_document['data']['attributes'][$field_name] = NULL;
                return $expected_document;
            }
            unset($expected_document['data']['attributes'][$field_name]);
        }
        return $expected_document;
    }
    
    /**
     * Ensure the anonymous and authenticated roles have no permissions at all.
     */
    protected function revokePermissions() : void {
        $user_role = Role::load(RoleInterface::ANONYMOUS_ID);
        foreach ($user_role->getPermissions() as $permission) {
            $user_role->revokePermission($permission);
        }
        $user_role->save();
        assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.');
        $user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
        foreach ($user_role->getPermissions() as $permission) {
            $user_role->revokePermission($permission);
        }
        $user_role->save();
        assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.');
    }
    
    /**
     * Generates an X-Drupal-Dynamic-Cache header value based on cacheability.
     *
     * @param array $cache_context
     *   Cache context.
     * @param int|null $cache_max_age
     *   (optional) Cache max age.
     *
     * @return 'UNCACHEABLE (poor cacheability)'|'MISS'
     *   The X-Drupal-Dynamic-Cache header value.
     */
    protected function generateDynamicPageCacheExpectedHeaderValue(array $cache_context, ?int $cache_max_age = NULL) : string {
        // MISS or UNCACHEABLE (poor cacheability) depends on data.
        // It must not be HIT.
        return $cache_max_age === 0 || !empty(array_intersect([
            'user',
            'session',
        ], $cache_context)) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
    }

}

Classes

Title Deprecated Summary
ResourceTestBase Subclass this for every JSON:API resource type.

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