diff --git a/.env.testing b/.env.testing index 3d2af61..dc32f71 100644 --- a/.env.testing +++ b/.env.testing @@ -1,5 +1,6 @@ -TEST_SITE_DB_DSN=mysql:host=mysql;dbname=wordpress -TEST_SITE_DB_HOST=mysql +TEST_SITE_DB_DSN=mysql:host=codecept_db;dbname=wordpress +TEST_SITE_DB_HOST=codecept_db +TEST_SITE_DB_PORT=3306 TEST_SITE_DB_NAME=wordpress TEST_SITE_DB_USER=wordpress TEST_SITE_DB_PASSWORD=password @@ -9,10 +10,10 @@ TEST_SITE_ADMIN_PASSWORD=password TEST_SITE_WP_ADMIN_PATH=/wp-admin WP_ROOT_FOLDER=/var/www/html TEST_DB_NAME=wordpress -TEST_DB_HOST=mysql +TEST_DB_HOST=codecept_db TEST_DB_USER=wordpress TEST_DB_PASSWORD=password TEST_TABLE_PREFIX=wp_ -TEST_SITE_WP_URL=localhost:8080 -TEST_SITE_WP_DOMAIN=localhost:8080 +TEST_SITE_WP_URL=http://localhost +TEST_SITE_WP_DOMAIN=localhost TEST_SITE_ADMIN_EMAIL=admin@example.com diff --git a/.github/workflows/continous-integration.yml b/.github/workflows/continous-integration.yml index bce3812..54b312f 100644 --- a/.github/workflows/continous-integration.yml +++ b/.github/workflows/continous-integration.yml @@ -50,13 +50,20 @@ jobs: composer install composer require codeception/module-asserts:* \ codeception/util-universalframework:* \ - codeception/module-rest:* \ + codeception/module-cli:* \ + codeception/module-db:* \ + codeception/module-filesystem:* \ + codeception/module-phpbrowser:* \ + codeception/module-webdriver:* \ + wp-cli/wp-cli-bundle \ lucatume/wp-browser:^3.1 - name: Run Codeception Tests w/ Docker. env: PHP_VERSION: ${{ matrix.php }} - run: composer run-codeception -- -- --coverage --coverage-xml + run: | + composer run-codeception -- -- functional + composer run-codeception -- -- wpunit --coverage --coverage-xml @@ -65,8 +72,7 @@ jobs: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | docker-compose run --rm \ - --workdir=/var/www/html/wp-content/plugins/wp-graphql-testcase \ --user $(id -u) \ -e COVERALLS_REPO_TOKEN=$COVERALLS_REPO_TOKEN \ - wordpress \ + codeception_testing \ vendor/bin/php-coveralls -v diff --git a/.gitignore b/.gitignore index b033d2f..a08b9f5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,13 @@ .vscode # Composer files. -vendor/ +vendor/* # Local test configuration files. codeception.yml tests/*.suite.yml -.env.testing.local +.env.* +!.env.testing # Patchwork cache folder. cache/* @@ -21,6 +22,7 @@ composer.lock # Some local vim files tags *.swp +*.sql # Release notes .rel diff --git a/codeception.dist.yml b/codeception.dist.yml index 0af5afc..0b895ef 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -9,7 +9,8 @@ coverage: enabled: true include: - src/* - exclude: + exclude: + - src/Codeception/Module/* - src/TestCase/WPGraphQLUnitTestCase.php - src/Logger/PHPUnitLogger.php show_only_summary: false @@ -26,3 +27,4 @@ extensions: - Codeception\Command\GenerateWPXMLRPC params: - .env.testing + - .env.docker \ No newline at end of file diff --git a/composer.json b/composer.json index a508dc2..1d62007 100644 --- a/composer.json +++ b/composer.json @@ -17,24 +17,31 @@ "src/" ] }, + "repositories": [ + { + "type": "composer", + "url": "https://wpackagist.org" + } + ], "require": { "php-extended/polyfill-php80-str-utils": "^1.3" }, "require-dev": { "composer/installers": "^1.9", "johnpbloch/wordpress": "^6.1", - "wp-graphql/wp-graphql": "^1.1.8", "squizlabs/php_codesniffer": "^3.5", "automattic/vipwpcs": "^2.3", "wp-coding-standards/wpcs": "^2.3", - "php-coveralls/php-coveralls": "2.4.3" + "php-coveralls/php-coveralls": "2.4.3", + "wpackagist-plugin/wp-graphql": "^1.26" }, "scripts": { - "cli": "docker-compose run --rm --workdir=/var/www/html/wp-content/plugins/wp-graphql-testcase --user $(id -u) wordpress wait_for_it $TEST_DB -s -t 300 --", - "codeception": "codecept run wpunit --", + "run_phpunit_env": "docker-compose run --rm --workdir=/var/www/html/wp-content/plugins/wp-graphql-testcase --user $(id -u) wp_phpunit_testing wait-for-it $TEST_DB -s -t 300 --", + "run_codecept_env": "docker-compose run --rm --user $(id -u) codeception_testing wait-for-it $TEST_DB -s -t 300 --", + "codeception": "codecept run --", "phpunit": "phpunit --", - "run-codeception": "env TEST_DB=mysql:3306 composer cli vendor/bin/codecept run wpunit", - "run-phpunit": "env TEST_DB=mysql_phpunit:3306 composer cli vendor/bin/phpunit" + "run-codeception": "env TEST_DB=codecept_db:3306 composer run_codecept_env vendor/bin/codecept run", + "run-phpunit": "env TEST_DB=phpunit_db:3306 composer run_phpunit_env vendor/bin/phpunit" }, "extra": { "wordpress-install-dir": "local/public", @@ -45,13 +52,16 @@ "suggest": { "codeception/module-asserts": "Needed for \\Tests\\WPGraphQL\\TestCase\\WPGraphQLTestcase to work.", "codeception/util-universalframework": "Needed for \\Tests\\WPGraphQL\\TestCase\\WPGraphQLTestcase to work.", - "codeception/module-rest": "Needed for \\Tests\\WPGraphQL\\TestCase\\WPGraphQLTestcase to work.", "lucatume/wp-browser": "Needed for \\Tests\\WPGraphQL\\TestCase\\WPGraphQLTestcase to work.", "phpunit/phpunit": "Needed for \\Tests\\WPGraphQL\\TestCase\\WPGraphQLUnitTestcase to work.", "wp-phpunit/wp-phpunit": "Needed for \\Tests\\WPGraphQL\\TestCase\\WPGraphQLUnitTestcase to work.", - "yoast/phpunit-polyfills": "Needed for \\Tests\\WPGraphQL\\TestCase\\WPGraphQLUnitTestcase to work." + "yoast/phpunit-polyfills": "Needed for \\Tests\\WPGraphQL\\TestCase\\WPGraphQLUnitTestcase to work.", + "guzzlehttp/guzzle": "Needed for \\Tests\\WPGraphQL\\Codeception\\Module\\WPGraphQL to work." }, "config": { + "optimize-autoloader": true, + "process-timeout": 0, + "sort-packages": true, "allow-plugins": { "composer/installers": true, "johnpbloch/wordpress-core-installer": true, diff --git a/docker-compose.yml b/docker-compose.yml index 2cb0395..8ad413e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,40 +2,82 @@ version: '3' services: - mysql: + codecept_db: image: mariadb:10.2 environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: wordpress MYSQL_USER: wordpress MYSQL_PASSWORD: password + networks: + codecept: + aliases: + - codecept_db - wordpress: - image: wp-graphql/wordpress:${WP_VERSION:-latest} + codeception_testing: + image: wp-graphql/codeception-testing:${WP_VERSION:-latest} build: context: ./docker + dockerfile: codeception.Dockerfile args: PHP_VERSION: ${PHP_VERSION:-8.0} depends_on: - - mysql - - mysql_phpunit + - codecept_db ports: - "8080:80" volumes: - ./local/public:/var/www/html # WP core files. - .:/var/www/html/wp-content/plugins/wp-graphql-testcase - - ./local/config/wp-config.php:/var/www/html/wp-config.php - - ./local/config/wp-tests-config.php:/var/www/html/wp-tests-config.php + - ./local/config/.htaccess:/var/www/html/.htaccess + - ./local/config/enable-app-passwords.php:/var/www/html/wp-content/mu-plugins/enable-app-passwords.php + env_file: .env.testing environment: + WORDPRESS_DOMAIN: localhost COMPOSER_HOME: /tmp/.composer APACHE_RUN_USER: "#1000" # Ensure Apache can write to the filesystem. - WP_TESTS_DIR: /var/www/html/wp-content/plugins/wp-graphql-testcase/vendor/wp-phpunit/wp-phpunit - WP_PHPUNIT__TESTS_CONFIG: /var/www/html/wp-tests-config.php + networks: + codecept: + aliases: + - codeception_testing - mysql_phpunit: + phpunit_db: image: mariadb:10.2 restart: always environment: MYSQL_ALLOW_EMPTY_PASSWORD: "yes" MYSQL_DATABASE: "wordpress" - MYSQL_ROOT_PASSWORD: "" \ No newline at end of file + MYSQL_ROOT_PASSWORD: "" + networks: + phpunit: + aliases: + - phpunit_db + + wp_phpunit_testing: + image: wp-graphql/wp-phpunit-testing:${WP_VERSION:-latest} + build: + context: ./docker + dockerfile: wp-phpunit.Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.0} + depends_on: + - phpunit_db + ports: + - "8081:80" + volumes: + - ./local/public:/var/www/html # WP core files. + - .:/var/www/html/wp-content/plugins/wp-graphql-testcase + - ./local/config/wp-tests-config.php:/var/www/html/wp-tests-config.php + env_file: .env.testing + environment: + COMPOSER_HOME: /tmp/.composer + APACHE_RUN_USER: "#1000" # Ensure Apache can write to the filesystem. + WP_TESTS_DIR: /var/www/html/wp-content/plugins/wp-graphql-testcase/vendor/wp-phpunit/wp-phpunit + WP_PHPUNIT__TESTS_CONFIG: /var/www/html/wp-tests-config.php + networks: + phpunit: + aliases: + - wp_phpunit_testing + +networks: + phpunit: + codecept: \ No newline at end of file diff --git a/docker/codeception.Dockerfile b/docker/codeception.Dockerfile new file mode 100644 index 0000000..a765746 --- /dev/null +++ b/docker/codeception.Dockerfile @@ -0,0 +1,54 @@ +ARG PHP_VERSION=8.1 + +FROM wordpress:php${PHP_VERSION}-apache + +# See: https://xdebug.org/docs/compat to match the Xdebug version with the PHP version. +ARG XDEBUG_VERSION=3.3.1 + +RUN apt-get update; \ + apt-get install -y --no-install-recommends \ + # WP-CLI dependencies. + bash less default-mysql-client git \ + # MailHog dependencies. + msmtp; + +COPY php.ini /usr/local/etc/php/php.ini + +RUN pecl install "xdebug-${XDEBUG_VERSION}"; \ + docker-php-ext-enable xdebug + +ENV XDEBUG_MODE=coverage + +# Install PDO MySQL driver. +RUN docker-php-ext-install \ + pdo_mysql + +ENV WP_ROOT_FOLDER="/var/www/html" +ENV WORDPRESS_DB_HOST=${TEST_SITE_DB_HOST} +ENV WORDPRESS_DB_PORT=${TEST_SITE_DB_PORT} +ENV WORDPRESS_DB_USER=${TEST_SITE_DB_USER} +ENV WORDPRESS_DB_PASSWORD=${TEST_SITE_DB_PASSWORD} +ENV WORDPRESS_DB_NAME=${TEST_SITE_DB_NAME} +ENV PLUGINS_DIR="${WP_ROOT_FOLDER}/wp-content/plugins" +ENV PROJECT_DIR="${PLUGINS_DIR}/wp-graphql-testcase" + +WORKDIR $PROJECT_DIR + +# Set up Apache +RUN echo 'ServerName localhost' >> /etc/apache2/apache2.conf +RUN a2enmod rewrite + +ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /usr/local/bin/wait-for-it +RUN chmod 755 /usr/local/bin/wait-for-it + +ADD https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar /usr/local/bin/wp +RUN chmod 755 /usr/local/bin/wp + +# Remove exec statement from base entrypoint script. +RUN sed -i '$d' /usr/local/bin/docker-entrypoint.sh + +# Set up entrypoint +COPY entrypoint.sh /usr/local/bin/app-entrypoint.sh +RUN chmod 755 /usr/local/bin/app-entrypoint.sh +ENTRYPOINT ["app-entrypoint.sh"] +CMD ["apache2-foreground"] \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..38440a7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,81 @@ +#!/bin/bash + + +work_dir=$(pwd) + +cd "${WP_ROOT_FOLDER}" || exit + +# Run WordPress docker entrypoint. +# shellcheck disable=SC1091 +. docker-entrypoint.sh 'apache2' + +set +u + +# Ensure mysql is loaded +wait-for-it -s -t 300 "${TEST_SITE_DB_HOST}:${TEST_SITE_DB_PORT:-3306}" -- echo "Application database is operationally..." + +if [ -f "${WP_ROOT_FOLDER}/wp-config.php" ]; then + echo "Deleting old wp-config.php" + rm -rf "${WP_ROOT_FOLDER}/wp-config.php" +fi + +echo "Creating wp-config.php..." +wp config create \ + --path="${WP_ROOT_FOLDER}" \ + --dbname="${TEST_SITE_DB_NAME}" \ + --dbuser="${TEST_SITE_DB_USER}" \ + --dbpass="${TEST_SITE_DB_PASSWORD}" \ + --dbhost="${TEST_SITE_DB_HOST}" \ + --dbprefix="${TEST_TABLE_PREFIX}" \ + --skip-check \ + --quiet \ + --allow-root + +# Install WP if not yet installed +echo "Installing WordPress..." +wp core install \ + --path="${WP_ROOT_FOLDER}" \ + --url="${TEST_SITE_WP_URL}" \ + --title='Test' \ + --admin_user="${TEST_SITE_ADMIN_USERNAME}" \ + --admin_password="${TEST_SITE_ADMIN_PASSWORD}" \ + --admin_email="${TEST_SITE_ADMIN_EMAIL}" \ + --allow-root + +wp plugin activate wp-graphql --allow-root + +if [ -f "${PROJECT_DIR}/tests/codeception/_data/dump.sql" ]; then + rm -rf "${PROJECT_DIR}/tests/codeception/_data/dump.sql" +fi + +wp user application-password delete 1 --all --allow-root + +app_user="admin" +app_password=$(wp user application-password create 1 testing --porcelain --allow-root) + +echo "Creating .env.docker file..." +echo TEST_SITE_ADMIN_APP_PASSWORD="$(echo -n "${app_user}:${app_password}" | base64)" > "$PROJECT_DIR/.env.docker" +echo TEST_SITE_WP_DOMAIN="${TEST_SITE_WP_DOMAIN}" >> "$PROJECT_DIR/.env.docker" +echo TEST_SITE_WP_URL="${TEST_SITE_WP_URL}" >> "$PROJECT_DIR/.env.docker" + +echo "Dumping app database..." +wp db export "${PROJECT_DIR}/tests/codeception/_data/dump.sql" \ + --dbuser="${TEST_SITE_DB_USER}" \ + --dbpass="${TEST_SITE_DB_PASSWORD}" \ + --skip-plugins \ + --skip-themes \ + --allow-root + +wp config set WP_SITEURL "${TEST_SITE_WP_URL}" --allow-root +wp config set WP_HOME "${TEST_SITE_WP_URL}" --allow-root + +echo "Setting pretty permalinks..." +wp rewrite structure '/%year%/%monthnum%/%postname%/' --allow-root + +service apache2 start + +echo "Running WordPress version: $(wp core version --allow-root) at $(wp option get home --allow-root)" + +cd "${work_dir}" || exit + +exec "$@" \ No newline at end of file diff --git a/docker/Dockerfile b/docker/wp-phpunit.Dockerfile similarity index 87% rename from docker/Dockerfile rename to docker/wp-phpunit.Dockerfile index e95ee7e..66a1613 100644 --- a/docker/Dockerfile +++ b/docker/wp-phpunit.Dockerfile @@ -23,5 +23,5 @@ ENV XDEBUG_MODE=coverage RUN docker-php-ext-install \ pdo_mysql -ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /usr/local/bin/wait_for_it -RUN chmod 755 /usr/local/bin/wait_for_it \ No newline at end of file +ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /usr/local/bin/wait-for-it +RUN chmod 755 /usr/local/bin/wait-for-it \ No newline at end of file diff --git a/local/config/.htaccess b/local/config/.htaccess new file mode 100644 index 0000000..2130d24 --- /dev/null +++ b/local/config/.htaccess @@ -0,0 +1,15 @@ +# BEGIN WordPress +# The directives (lines) between "BEGIN WordPress" and "END WordPress" are +# dynamically generated, and should only be modified via WordPress filters. +# Any changes to the directives between these markers will be overwritten. + +RewriteEngine On +RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] +RewriteBase / +RewriteRule ^index\.php$ - [L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.php [L] + + +# END WordPress diff --git a/local/config/enable-app-passwords.php b/local/config/enable-app-passwords.php new file mode 100644 index 0000000..334b4a0 --- /dev/null +++ b/local/config/enable-app-passwords.php @@ -0,0 +1,7 @@ + src/TestCase/WPGraphQLTestCase.php src/Logger/CodeceptLogger.php + src/Codeception vendor/ local/ diff --git a/src/Codeception/Module/QueryAsserts.php b/src/Codeception/Module/QueryAsserts.php new file mode 100644 index 0000000..7ebebca --- /dev/null +++ b/src/Codeception/Module/QueryAsserts.php @@ -0,0 +1,193 @@ +logger = new Signal(); + } + + /** + * Copy of "GraphQLRelay\Relay::toGlobalId()" function. + * + * @param string $type The type of the object. + * @param string $id The ID of the object. + * + * @return string + */ + public function asRelayId($type, $id) { + return base64_encode( $type . ':' . $id ); + } + + /** + * Returns an expected "Field" type data object. + * + * @param string $path Path to the data being tested. + * @param mixed $expected_value Expected value of the object being evaluted. + * @return array + */ + public function expectField( string $path, $expected_value ) { + $type = $this->get_not() . 'FIELD'; + return compact( 'type', 'path', 'expected_value' ); + } + + /** + * Returns an expected "Object" type data object. + * + * @param string $path Path to the data being tested. + * @param array $expected_value Expected value of the object being evaluted. + * @return array + */ + public function expectObject( string $path, array $expected_value ) { + $type = $this->get_not() . 'OBJECT'; + return compact( 'type', 'path', 'expected_value' ); + } + + /** + * Returns an expected "Node" type data object. + * + * @param string $path Path to the data being tested. + * @param array $expected_value Expected value of the node being evaluted. + * @param integer|null $expected_index Expected index of the node being evaluted. + * @return array + */ + public function expectNode( string $path, array $expected_value, $expected_index = null ) { + $type = $this->get_not() . 'NODE'; + return compact( 'type', 'path', 'expected_value', 'expected_index' ); + } + + /** + * Returns an expected "Edge" type data object. + * + * @param string $path Path to the data being tested. + * @param array $expected_value Expected value of the edge being evaluted. + * @param integer|null $expected_index Expected index of the edge being evaluted. + * @return array + */ + public function expectEdge( string $path, array $expected_value, $expected_index = null ) { + $type = $this->get_not() . 'EDGE'; + return compact( 'type', 'path', 'expected_value', 'expected_index' ); + } + + /** + * Triggers the "not" flag for the next expect*() call. + * + * @return WPGraphQLTestCommon + */ + public function not() { + $this->not = '!'; + return $this; + } + + /** + * Clears the "not" flag and return the proper prefix. + * + * @return string + */ + private function get_not() { + if ( ! $this->not ) { + return ''; + } + + $prefix = $this->not; + $this->not = null; + return $prefix; + } + + /** + * Returns an expected "location" error data object. + * + * @param string $path Path to the data being tested. + * @return array + */ + public function expectErrorPath( string $path ) { + $type = 'ERROR_PATH'; + return compact( 'type', 'path' ); + } + + /** + * Returns an expected "Edge" type data object. + * + * @param string $path Path to the data being tested. + * @param int|null $search_type Expected index of the edge being evaluted. + * @return array + */ + public function expectErrorMessage( string $needle, int $search_type = self::MESSAGE_EQUALS ) { + $type = 'ERROR_MESSAGE'; + return compact( 'type', 'needle', 'search_type' ); + } + + /** + * Reports an error identified by $message if $response is not a valid GraphQL Response. + * + * @param array $response GraphQL query response object. + * @param string $message Error message. + * @return void + */ + public function assertResponseIsValid( $response, $message = '' ) { + $this->assertThat( + $response, + new QueryConstraint( $this->logger ), + $message + ); + } + + /** + * Reports an error identified by $message if $response does not contain all data + * and specifications defined in the $expected array. + * + * @param array $response GraphQL query response. + * @param array $expected List of expected data objects. + * @param string $message Error message. + */ + public function assertQuerySuccessful( array $response, array $expected = [], $message = '' ) { + $this->assertThat( + $response, + new QuerySuccessfulConstraint( $this->logger, $expected ), + $message + ); + } + + /** + * Reports an error identified by $message if $response does not contain the error + * specifications defined in the $expected array. + * + * @param array $response GraphQL query response. + * @param array $expected Expected error data. + * @param string $message Error message. + * @return void + */ + public function assertQueryError( array $response, array $expected = [], $message = '' ) { + $this->assertThat( + $response, + new QueryErrorConstraint( $this->logger, $expected ), + $message + ); + } +} \ No newline at end of file diff --git a/src/Codeception/Module/WPGraphQL.php b/src/Codeception/Module/WPGraphQL.php new file mode 100644 index 0000000..e6dfff5 --- /dev/null +++ b/src/Codeception/Module/WPGraphQL.php @@ -0,0 +1,367 @@ + + */ + protected array $config = [ + 'endpoint' => '', + 'auth_header' => '', + ]; + + protected array $requiredFields = [ + 'endpoint', + ]; + + /** @var \GuzzleHttp\Client */ + private $client = null; + + /** @var \Tests\WPGraphQL\Logger\CodeceptLogger */ + private $logger = null; + + private function getHeaders() { + $headers = [ 'Content-Type' => 'application/json' ]; + $auth_header = $this->config['auth_header']; + if ( ! empty( $auth_header ) ) { + $headers['Authorization'] = $auth_header; + } + + return $headers; + } + + /** + * Initializes the module + * + * @throws \Codeception\Exception\ModuleException Invalid endpoint. + * + * @return void + */ + public function _before( TestInterface $test ) { + $endpoint = $this->config['endpoint']; + if ( empty( $endpoint ) ) { + throw new ModuleException( $this, 'Invalid endpoint.' ); + } + $this->client = new \GuzzleHttp\Client( + [ + 'base_uri' => $endpoint, + 'timeout' => 300, + ] + ); + $this->logger = new \Tests\WPGraphQL\Logger\CodeceptLogger(); + } + + /** + * Sends a GET request to the GraphQL endpoint and returns a response + * + * @param string $query The GraphQL query to send. + * @param array $request_headers The headers to send with the request. + * + * @throws \Codeception\Exception\ModuleException Invalid endpoint | Invalid query. + * + * @return array + */ + public function getRawRequest( $query, $request_headers = [] ) { + $endpoint = $this->config['endpoint']; + if ( empty( $endpoint ) ) { + throw new ModuleException( $this, 'Invalid endpoint.' ); + } + + if ( empty( $query ) ) { + throw new ModuleException( $this, 'Invalid query.' ); + } + + $headers = array_merge( + $this->getHeaders(), + $request_headers + ); + + $this->logger->logData( "GET request to {$endpoint} with query: {$query}" ); + $this->logger->logData( "Headers: " . json_encode( $headers ) ); + + $response = $this->client->request( 'GET', "?query={$query}", [ 'headers' => $headers ] ); + + if ( empty( $response ) ) { + throw new ModuleException( $this, 'Invalid response.' ); + } + + $this->logger->logData( $response->getHeaders() ); + $this->logger->logData( $response->getBody() ); + + return $response; + } + + /** + * Sends a GET request to the GraphQL endpoint and returns the query results + * + * @param string $query The GraphQL query to send. + * @param array $request_headers The headers to send with the request. + * + * @throws \Codeception\Exception\ModuleException Invalid response | Empty response. + * + * @return array + */ + public function getRequest( $query, $request_headers = [] ) { + $response = $this->getRawRequest( $query, $request_headers ); + if ( $response->getStatusCode() !== 200 ) { + throw new ModuleException( $this, 'Invalid response.' ); + } + + if ( empty( $response->getBody() ) ) { + throw new ModuleException( $this, 'Empty response.' ); + } + + $queryResults = json_decode( $response->getBody(), true ); + + return $queryResults; + } + + /** + * Sends a POST request to the GraphQL endpoint and return a response + * + * @param string $query The GraphQL query to send. + * @param array $variables The variables to send with the query. + * @param string|null $request_headers The headers to send with the request. + * + * @throws \Codeception\Exception\ModuleException Invalid endpoint. + * + * @return array + */ + public function postRawRequest( $query, $variables = [], $request_headers = [] ) { + $endpoint = $this->config['endpoint']; + if ( empty( $endpoint ) ) { + throw new ModuleException( $this, 'Invalid endpoint.' ); + } + + if ( empty( $query ) ) { + throw new ModuleException( $this, 'Invalid query.' ); + } + + if ( ! is_array( $variables ) ) { + throw new ModuleException( $this, 'Invalid variables.' ); + } + + $headers = array_merge( + $this->getHeaders(), + $request_headers + ); + + $this->logger->logData( "GET request to {$endpoint} with query: {$query}" ); + $this->logger->logData( "Variables: " . json_encode( $variables ) ); + $this->logger->logData( "Headers: " . json_encode( $headers ) ); + + $response = $this->client->request( + 'POST', + '', + [ + 'headers' => $headers, + 'body' => json_encode( [ 'query' => $query, 'variables' => $variables ] ), + ] + ); + + if ( empty( $response ) ) { + throw new ModuleException( $this, 'Invalid response.' ); + } + + $this->logger->logData( $response->getHeaders() ); + $this->logger->logData( $response->getBody() ); + + return $response; + } + + /** + * Sends POST request to the GraphQL endpoint and returns the query results + * + * @param string $query The GraphQL query to send. + * @param array $variables The variables to send with the query. + * @param string|null $request_headers The headers to send with the request. + * + * @throws \Codeception\Exception\ModuleException Invalid endpoint. + * + * @return array + */ + public function postRequest( $query, $variables = [], $request_headers = [] ) { + $response = $this->postRawRequest( $query, $variables, $request_headers ); + if ( $response->getStatusCode() !== 200 ) { + throw new ModuleException( $this, 'Invalid response.' ); + } + + if ( empty( $response->getBody() ) ) { + throw new ModuleException( $this, 'Empty response.' ); + } + + $queryResults = json_decode( $response->getBody(), true ); + + return $queryResults; + } + + /** + * Sends a batch query request to the GraphQL endpoint and return a response + * + * @param object{'query': string, 'variables': array} $operations An array of operations to send. + * @param array $request_headers An array of headers to send with the request. + * + * @throws \Codeception\Exception\ModuleException Invalid endpoint. + * + * @return array + */ + public function batchRawRequest( $operations, $request_headers = [] ) { + $endpoint = $this->config['endpoint']; + if ( empty( $endpoint ) ) { + throw new ModuleException( $this, 'Invalid endpoint.' ); + } + + if ( empty( $operations ) ) { + throw new ModuleException( $this, 'Invalid query.' ); + } + + $headers = array_merge( + $this->getHeaders(), + $request_headers + ); + + $response = $this->client->request( + 'POST', + '', + [ + 'headers' => $headers, + 'body' => json_encode( $operations ), + ] + ); + + if ( empty( $response ) ) { + throw new ModuleException( $this, 'Invalid response.' ); + } + + $this->logger->logData( $response->getHeaders() ); + $this->logger->logData( json_decode( $response->getBody() ) ); + + return $response; + } + + /** + * Sends a batch query request to the GraphQL endpoint and returns the query results + * + * @param object{'query': string, 'variables': array} $operations An array of operations to send. + * @param array $request_headers An array of headers to send with the request. + * + * @throws \Codeception\Exception\ModuleException Invalid endpoint. + * + * @return array + */ + public function batchRequest( $operations, $request_headers = [] ) { + $response = $this->batchRawRequest( $operations, $request_headers ); + if ( $response->getStatusCode() !== 200 ) { + throw new ModuleException( $this, 'Invalid response.' ); + } + + if ( empty( $response->getBody() ) ) { + throw new ModuleException( $this, 'Empty response.' ); + } + + $queryResults = json_decode( $response->getBody(), true ); + + return $queryResults; + } + + /** + * Sends a concurrent requests to the GraphQL endpoint and returns a response + * + * @param {'query': string, 'variables': array} $operations An array of operations to send. + * @param array $request_headers An array of headers to send with the request. + * @param int $stagger The time in milliseconds to stagger requests. + * + * @throws \Codeception\Exception\ModuleException Invalid endpoint. + * + * @return array + */ + public function concurrentRawRequests( $operations, $request_headers = [], $stagger = 800 ) { + $endpoint = $this->config['endpoint']; + if ( empty( $endpoint ) ) { + throw new ModuleException( $this, 'Invalid endpoint.' ); + } + + if ( empty( $operations ) ) { + throw new ModuleException( $this, 'Invalid query.' ); + } + + $headers = array_merge( + $this->getHeaders(), + $request_headers + ); + + $promises = []; + foreach ( $operations as $index => $operation ) { + $body = json_encode( $operation ); + $delay = $stagger * ($index + 1); + $connected = false; + $progress = function ( $downloadTotal, $downloadedBytes, $uploadTotal, $uploadedBytes ) use ( $index, &$connected ) { + if ( $uploadTotal === $uploadedBytes && 0 === $downloadTotal && ! $connected ) { + $this->logger->logData( + "Session mutation request $index connected @ " + . date( 'Y-m-d H:i:s', time() ) + ); + $connected = true; + } + }; + $promises[] = $this->client->postAsync( + '', + [ + 'body' => $body, + 'delay' => $delay, + 'progress' => $progress, + 'headers' => $headers, + ] + ); + } + + $responses = \GuzzleHttp\Promise\Utils::unwrap( $promises ); + \GuzzleHttp\Promise\Utils::settle( $promises )->wait(); + + return $responses; + } + + /** + * Sends a concurrent requests to the GraphQL endpoint and returns a response + * + * @param {'query': string, 'variables': array} $operations An array of operations to send. + * @param array $request_headers An array of headers to send with the request. + * @param int $stagger The time in milliseconds to stagger requests. + * + * @throws \Codeception\Exception\ModuleException Invalid endpoint. + * + * @return array + */ + public function concurrentRequests( $operations, $request_headers = [], $stagger = 800 ) { + $responses = $this->concurrentRawRequests( $operations, $request_headers, $stagger ); + if ( empty( $responses ) ) { + throw new ModuleException( $this, 'Invalid response.' ); + } + + $queryResults = []; + foreach ( $responses as $response ) { + if ( $response->getStatusCode() !== 200 ) { + throw new ModuleException( $this, 'Invalid response.' ); + } + + if ( empty( $response->getBody() ) ) { + throw new ModuleException( $this, 'Empty response.' ); + } + + $this->logger->logData( $response->getHeaders() ); + $this->logger->logData( json_decode( $response->getBody() ) ); + + $queryResults[] = json_decode( $response->getBody(), true ); + } + + return $queryResults; + } +} \ No newline at end of file diff --git a/src/Constraint/QueryConstraint.php b/src/Constraint/QueryConstraint.php index cc7a9d0..ae85556 100644 --- a/src/Constraint/QueryConstraint.php +++ b/src/Constraint/QueryConstraint.php @@ -38,12 +38,19 @@ class QueryConstraint extends Constraint { */ private $actual = null; + /** + * Error message for assertion failure. + * + * @var string + */ + protected $error_message = null; + /** - * List of errors trigger during validation. + * List of reasons for assertion failure. * * @var string[] */ - private $error_messages = []; + protected $error_details = []; /** * Constructor @@ -65,17 +72,17 @@ public function __construct($logger, array $expected = []) { */ protected function responseIsValid( $response, &$message = null ) { if ( empty( $response ) ) { - $this->error_messages[] = 'GraphQL query response is invalid.'; + $this->error_details[] = 'GraphQL query response is invalid.'; return false; } if ( array_keys( $response ) === range( 0, count( $response ) - 1 ) ) { - $this->error_messages[] = 'The GraphQL query response must be provided as an associative array.'; + $this->error_details[] = 'The GraphQL query response must be provided as an associative array.'; return false; } if ( 0 === count( array_intersect( array_keys( $response ), [ 'data', 'errors' ] ) ) ) { - $this->error_messages[] = 'A valid GraphQL query response must contain a "data" or "errors" object.'; + $this->error_details[] = 'A valid GraphQL query response must contain a "data" or "errors" object.'; return false; } @@ -94,7 +101,7 @@ protected function expectedDataFound( array $response, array $expected_data, str // Throw if "$expected_data" invalid. if ( empty( $expected_data['type'] ) ) { $this->logger->logData( [ 'INVALID_DATA_OBJECT' => $expected_data ] ); - $this->error_messages[] = "Invalid rule object provided for evaluation: \n\t " . json_encode( $expected_data, JSON_PRETTY_PRINT ); + $this->error_details[] = "Invalid rule object provided for evaluation: \n\t " . json_encode( $expected_data, JSON_PRETTY_PRINT ); return false; } @@ -132,7 +139,7 @@ protected function expectedDataFound( array $response, array $expected_data, str case $this->logger::NOT_FALSY: // Fail if data found at path is a falsy value (null, false, []). if ( empty( $actual_data ) && ! $reverse ) { - $this->error_messages[] = sprintf( + $this->error_details[] = sprintf( 'Expected data at path "%s" not to be falsy value. "%s" Given', $full_path, is_array( $actual_data ) ? '[]' : (string) $actual_data @@ -140,7 +147,7 @@ protected function expectedDataFound( array $response, array $expected_data, str return false; } elseif ( ! empty( $actual_data ) && $reverse ) { - $this->error_messages[] = sprintf( + $this->error_details[] = sprintf( 'Expected data at path "%s" to be falsy value. "%s" Given', $full_path, is_array( $actual_data ) ? "\n\n" . json_encode( $actual_data, JSON_PRETTY_PRINT ) : $actual_data @@ -155,7 +162,7 @@ protected function expectedDataFound( array $response, array $expected_data, str case $this->logger::IS_FALSY: // Fail if data found at path is not falsy value (null, false, 0, []). if ( ! empty( $actual_data ) && ! $reverse ) { - $this->error_messages[] = sprintf( + $this->error_details[] = sprintf( 'Expected data at path "%s" to be falsy value. "%s" Given', $full_path, is_array( $actual_data ) ? "\n\n" .json_encode( $actual_data, JSON_PRETTY_PRINT ) : $actual_data @@ -163,7 +170,7 @@ protected function expectedDataFound( array $response, array $expected_data, str return false; } elseif ( empty( $actual_data ) && $reverse ) { - $this->error_messages[] = sprintf( + $this->error_details[] = sprintf( 'Expected data at path "%s" not to be falsy value. "%s" Given', $full_path, is_array( $actual_data ) ? "\n\n" .json_encode( $actual_data, JSON_PRETTY_PRINT ) : $actual_data @@ -179,7 +186,7 @@ protected function expectedDataFound( array $response, array $expected_data, str default: // Check if "$expected_value" is not null if comparing to provided value. // Fail if no data found at path. if ( is_null( $actual_data ) && ! $reverse ) { - $this->error_messages[] = sprintf( 'No data found at path "%s"', $full_path ); + $this->error_details[] = sprintf( 'No data found at path "%s"', $full_path ); return false; } elseif ( @@ -187,7 +194,7 @@ protected function expectedDataFound( array $response, array $expected_data, str && $reverse && $expected_value === $this->logger::NOT_NULL ) { - $this->error_messages[] = sprintf( 'Unexpected data found at path "%s"', $full_path ); + $this->error_details[] = sprintf( 'Unexpected data found at path "%s"', $full_path ); return false; } @@ -214,7 +221,7 @@ protected function expectedDataFound( array $response, array $expected_data, str case $is_field_rule: // Fail if matcher fails if ( ! $this->{$matcher}( $actual_data, $expected_value, $match_wanted, $path ) ) { - $this->error_messages[] = sprintf( + $this->error_details[] = sprintf( 'Data found at path "%1$s" %2$s the provided value', $path, $match_wanted ? 'doesn\'t match' : 'shouldn\'t match' @@ -236,6 +243,12 @@ protected function expectedDataFound( array $response, array $expected_data, str $nested_rule_passing = $this->expectedDataFound( $response, $nested_rule, $next_path ); if ( ! $nested_rule_passing ) { + $this->error_details[] = sprintf( + "Data found at path \"%1\$s\" %2\$s fails the following rules: \n\t\t %3\$s", + $next_path, + $match_wanted ? 'doesn\'t match' : 'shouldn\'t match', + \json_encode( $nested_rule, JSON_PRETTY_PRINT ) + ); return false; } } @@ -245,13 +258,13 @@ protected function expectedDataFound( array $response, array $expected_data, str // Fail if matcher fails. if ( ! $this->{$matcher}( $actual_data, $expected_value, $match_wanted, $path ) ) { if ( $check_order ) { - $this->error_messages[] = sprintf( + $this->error_details[] = sprintf( 'Data found at path "%1$s" %2$s the provided value', $full_path, $match_wanted ? 'doesn\'t match' : 'shouldn\'t match' ); } else { - $this->error_messages[] = sprintf( + $this->error_details[] = sprintf( '%1$s found in %2$s list at path "%3$s"', $match_wanted ? 'Unexpected data ' : 'Expected data not ', strtolower( $type ), @@ -266,7 +279,7 @@ protected function expectedDataFound( array $response, array $expected_data, str return true; default: $this->logger->logData( ['INVALID_DATA_OBJECT', $expected_data ] ); - $this->error_messages[] = "Invalid data object provided for evaluation. \n\t" . json_encode( $expected_data, JSON_PRETTY_PRINT ); + $this->error_details[] = "Invalid data object provided for evaluation. \n\t" . json_encode( $expected_data, JSON_PRETTY_PRINT ); return false; } } @@ -329,7 +342,7 @@ function( $v ) { } // Fail if no match found. - $this->error_messages[] = sprintf( 'No errors found that occured at path "%1$s"', $path ); + $this->error_details[] = sprintf( 'No errors found that occured at path "%1$s"', $path ); return false; case 'ERROR_MESSAGE': $this->logger->logData( @@ -361,7 +374,7 @@ function( $v ) { } // Fail if no match found. - $this->error_messages[] = sprintf( + $this->error_details[] = sprintf( 'No errors found with a message that %1$s "%2$s"', $search_type_messages[ $search_type ], $needle @@ -370,7 +383,7 @@ function( $v ) { return false; default: $this->logger->logData( ['INVALID_DATA_OBJECT', $expected_data ] ); - $this->error_messages[] = "Invalid data object provided for evaluation. \n\t" . json_encode( $expected_data, JSON_PRETTY_PRINT ); + $this->error_details[] = "Invalid data object provided for evaluation. \n\t" . json_encode( $expected_data, JSON_PRETTY_PRINT ); return false; } } @@ -567,6 +580,7 @@ protected function findSubstring( $haystack, $needle, $search_type ) { public function matches($response): bool { // Ensure response is valid. if ( ! $this->responseIsValid( $response ) ) { + $this->error_message = 'GraphQL response is invalid'; return false; } @@ -574,7 +588,19 @@ public function matches($response): bool { } public function failureDescription($other): string { - return "GraphQL response failed validation: \n\n\t• " . implode( "\n\n\t• ", $this->error_messages ); + $output = ''; + + if ( ! empty( $this->error_message ) ) { + $output .= $this->error_message; + } else { + $output .= 'GraphQL response failed validation'; + } + + if ( ! empty( $this->error_details ) ) { + $output .= ": \n\n\t• " . implode( "\n\n\t• ", $this->error_details ); + } + + return $output; } /** diff --git a/src/Constraint/QueryErrorConstraint.php b/src/Constraint/QueryErrorConstraint.php index b303b56..f69f3e2 100644 --- a/src/Constraint/QueryErrorConstraint.php +++ b/src/Constraint/QueryErrorConstraint.php @@ -22,7 +22,7 @@ public function matches($response): bool { // Throw if response has errors. if ( ! array_key_exists( 'errors', $response ) ) { - $this->error_messages[] = 'No errors was thrown during the previous GraphQL requested. May need to use "--debug" flag to see contents of previous request.'; + $this->error_message = "No errors was thrown during the previous GraphQL requested. \n Use \"--debug\" flag to see contents of previous request."; return false; } @@ -37,7 +37,9 @@ public function matches($response): bool { foreach( $this->validationRules as $expected_data ) { if ( empty( $expected_data['type'] ) ) { $this->logger->logData( array( 'INVALID_DATA_OBJECT' => $expected_data ) ); - $this->error_messages[] = 'Invalid data object provided for evaluation.'; + $this->error_details[] = 'Invalid data object provided for evaluation.'; + $data_passed = false; + $error_passed = false; continue; } @@ -54,6 +56,7 @@ public function matches($response): bool { } if ( ! $data_passed || ! $error_passed) { + $this->error_message = 'The GraphQL response failed the following steps in validation'; return false; } diff --git a/src/Constraint/QuerySuccessfulConstraint.php b/src/Constraint/QuerySuccessfulConstraint.php index 98713d1..1510572 100644 --- a/src/Constraint/QuerySuccessfulConstraint.php +++ b/src/Constraint/QuerySuccessfulConstraint.php @@ -24,7 +24,7 @@ public function matches($response): bool { // Throw if response has errors. if ( array_key_exists( 'errors', $response ) ) { - $this->error_messages[] = 'An error was thrown during the previous GraphQL requested. May need to use "--debug" flag to see contents of previous request.'; + $this->error_message = "An error was thrown during the previous GraphQL requested. \n Use \"--debug\" flag to see contents of previous request."; return false; } @@ -32,6 +32,7 @@ public function matches($response): bool { if ( empty( $this->validationRules ) ) { return true; } + // Check validation rules. $passed = true; @@ -42,6 +43,7 @@ public function matches($response): bool { } if ( ! $passed ) { + $this->error_message = 'The GraphQL response failed the following steps in validation'; return false; } diff --git a/tests/codeception/acceptance.suite.dist.yml b/tests/codeception/acceptance.suite.dist.yml index ceeaebd..8f4f72f 100644 --- a/tests/codeception/acceptance.suite.dist.yml +++ b/tests/codeception/acceptance.suite.dist.yml @@ -14,22 +14,19 @@ modules: config: WPDb: dsn: '%TEST_SITE_DB_DSN%' - user: '%TEST_SITE_DB_USER%' - password: '%TEST_SITE_DB_PASSWORD%' - dump: 'tests/_data/dump.sql' - #import the dump before the tests; this means the test site database will be repopulated before the tests. - populate: true - # re-import the dump between tests; this means the test site database will be repopulated between the tests. - cleanup: true + user: '%TEST_DB_USER%' + password: '%TEST_DB_PASSWORD%' + dump: 'tests/codeception/_data/dump.sql' + populate: false + cleanup: false waitlock: 10 url: '%TEST_SITE_WP_URL%' - urlReplacement: true #replace the hardcoded dump URL with the one above - tablePrefix: '%TEST_SITE_TABLE_PREFIX%' + urlReplacement: true + tablePrefix: '%TEST_TABLE_PREFIX%' + WPBrowser: url: '%TEST_SITE_WP_URL%' + wpRootFolder: '%WP_ROOT_FOLDER%' adminUsername: '%TEST_SITE_ADMIN_USERNAME%' adminPassword: '%TEST_SITE_ADMIN_PASSWORD%' - adminPath: '%TEST_SITE_WP_ADMIN_PATH%' - headers: - X_TEST_REQUEST: 1 - X_WPBROWSER_REQUEST: 1 \ No newline at end of file + adminPath: '/wp-admin' \ No newline at end of file diff --git a/tests/codeception/functional.suite.dist.yml b/tests/codeception/functional.suite.dist.yml index 140252e..077a1a2 100644 --- a/tests/codeception/functional.suite.dist.yml +++ b/tests/codeception/functional.suite.dist.yml @@ -8,33 +8,51 @@ modules: enabled: - WPDb - WPBrowser - # - WPFilesystem + - WPFilesystem - Asserts + - \Tests\WPGraphQL\Codeception\Module\QueryAsserts + - \Tests\WPGraphQL\Codeception\Module\WPGraphQL - \Helper\Functional config: + \Tests\WPGraphQL\Codeception\Module\WPGraphQL: + endpoint: '%TEST_SITE_WP_URL%/graphql' + auth_header: 'Basic %TEST_SITE_ADMIN_APP_PASSWORD%' WPDb: dsn: '%TEST_SITE_DB_DSN%' - user: '%TEST_SITE_DB_USER%' - password: '%TEST_SITE_DB_PASSWORD%' - dump: 'tests/_data/dump.sql' + user: '%TEST_DB_USER%' + password: '%TEST_DB_PASSWORD%' + dump: 'tests/codeception/_data/dump.sql' populate: true cleanup: true waitlock: 10 url: '%TEST_SITE_WP_URL%' urlReplacement: true - tablePrefix: '%TEST_SITE_TABLE_PREFIX%' + tablePrefix: '%TEST_TABLE_PREFIX%' + WPBrowser: url: '%TEST_SITE_WP_URL%' + wpRootFolder: '%WP_ROOT_FOLDER%' adminUsername: '%TEST_SITE_ADMIN_USERNAME%' adminPassword: '%TEST_SITE_ADMIN_PASSWORD%' - adminPath: '%TEST_SITE_WP_ADMIN_PATH%' - headers: - X_TEST_REQUEST: 1 - X_WPBROWSER_REQUEST: 1 + adminPath: '/wp-admin' WPFilesystem: wpRootFolder: '%WP_ROOT_FOLDER%' plugins: '/wp-content/plugins' mu-plugins: '/wp-content/mu-plugins' themes: '/wp-content/themes' - uploads: '/wp-content/uploads' \ No newline at end of file + uploads: '/wp-content/uploads' + + WPLoader: + loadOnly: true + wpRootFolder: "%WP_ROOT_FOLDER%" + dbName: "%TEST_DB_NAME%" + dbHost: "%TEST_DB_HOST%" + dbUser: "%TEST_DB_USER%" + dbPassword: "%TEST_DB_PASSWORD%" + tablePrefix: "%TEST_TABLE_PREFIX%" + domain: "%TEST_SITE_WP_DOMAIN%" + adminEmail: "%TEST_SITE_ADMIN_EMAIL%" + title: "WPGraphQLTestcase" + plugins: ['wp-graphql/wp-graphql.php'] + activatePlugins: ['wp-graphql/wp-graphql.php'] diff --git a/tests/codeception/functional/QueryAssertsModuleTestCest.php b/tests/codeception/functional/QueryAssertsModuleTestCest.php new file mode 100644 index 0000000..cad82f5 --- /dev/null +++ b/tests/codeception/functional/QueryAssertsModuleTestCest.php @@ -0,0 +1,76 @@ + [ + 'post' => [ + 'id' => 'cG9zdDox' + ] + ], + ]; + + $I->assertResponseIsValid( $data ); + + $data = [ + 'errors' => [ + [ 'message' => 'Invalid ID' ] + ], + 'data' => null, + ]; + + $I->assertResponseIsValid( $data, false ); + } + + public function testAssertQuerySuccessful( FunctionalTester $I ) { + $data = [ + 'data' => [ + 'post' => [ + 'id' => 'cG9zdDox' + ] + ], + ]; + + $I->assertQuerySuccessful( $data ); + + $expected = [ + $I->expectNode( + 'post', + [ + $I->expectField( 'id', $I->asRelayId( 'post', 1 ) ) + ] + ) + ]; + + $I->assertQuerySuccessful( $data, $expected ); + } + + public function testAssertQueryError( FunctionalTester $I ) { + $data = [ + 'errors' => [ + 'message' => "Internal server error", + 'extensions' => [ + 'category' => 'internal', + ], + 'locations' => [ + [ + 'line' => 2, + 'column' => 3, + ], + ], + 'path' => [ + 'post', + ], + ], + 'data' => [ 'post' => null ], + ]; + + $I->assertQueryError( $data ); + } +} diff --git a/tests/codeception/functional/WPGraphQLModuleTestCest.php b/tests/codeception/functional/WPGraphQLModuleTestCest.php new file mode 100644 index 0000000..8cca2a4 --- /dev/null +++ b/tests/codeception/functional/WPGraphQLModuleTestCest.php @@ -0,0 +1,293 @@ +wantTo( 'send a GET request to the GraphQL endpoint and return a response' ); + + $I->haveManyPostsInDatabase(5); + $post_id = $I->havePostInDatabase( [ 'post_title' => 'Test Post' ] ); + + $query = '{ + posts { + nodes { + id + title + } + } + }'; + + $response = $I->getRequest( $query ); + $expected = [ + $I->expectNode( + 'posts.nodes', + [ + $I->expectField( 'id', $I->asRelayId( 'post', $post_id ) ), + $I->expectField( 'title', 'Test Post' ) + ] + ) + ]; + + $I->assertQuerySuccessful( $response, $expected ); + } + + public function testPostRequest( FunctionalTester $I, $scenario ) { + $I->wantTo( 'send a POST request to the GraphQL endpoint and return a response' ); + + $query = 'mutation ( $input: CreatePostInput! ) { + createPost( input: $input ) { + post { + id + title + } + } + }'; + + $variables = [ + 'input' => [ + 'title' => 'Test Post', + 'content' => 'Test Post content', + 'slug' => 'test-post', + 'status' => 'PUBLISH' + ] + ]; + + $response = $I->postRequest( $query, $variables ); + $expected = [ + $I->expectObject( + 'createPost.post', + [ + $I->expectField( 'id', Signal::NOT_NULL ), + $I->expectField( 'title', 'Test Post' ) + ] + ) + ]; + + $I->assertQuerySuccessful( $response, $expected ); + } + + public function testBatchRequest( FunctionalTester $I, $scenario ) { + $I->wantTo( 'send a batch request to the GraphQL endpoint and return a response' ); + + $I->haveManyPostsInDatabase(20); + + $operations = [ + [ + 'query' => 'mutation ( $input: CreatePostInput! ) { + createPost( input: $input ) { + post { + id + title + slug + status + } + } + }', + 'variables' => [ + 'input' => [ + 'title' => 'Wowwy Zowwy 1', + 'content' => 'Wowwy Zowwy 1 content', + 'slug' => 'wowwy-zowwy-1', + 'status' => 'PUBLISH', + ] + ] + ], + [ + 'query' => 'mutation ( $input: CreatePostInput! ) { + createPost( input: $input ) { + post { + id + title + } + } + }', + 'variables' => [ + 'input' => [ + 'title' => 'Wowwy Zowwy 2', + 'content' => 'Wowwy Zowwy 2 content', + 'slug' => 'wowwy-zowwy-2', + 'status' => 'PUBLISH', + ] + ] + ], + [ + 'query' => '{ + posts(first: 2 where: { search: "Wowwy Zowwy" } ) { + nodes { + id + title + } + } + }' + ] + ]; + + $responses = $I->batchRequest( $operations ); + + $I->assertQuerySuccessful( + $responses[0], + [ + $I->expectObject( + 'createPost.post', + [ + $I->expectField( 'id', Signal::NOT_NULL ), + $I->expectField( 'title', 'Wowwy Zowwy 1' ) + ] + ) + ] + ); + + $I->assertQuerySuccessful( + $responses[1], + [ + $I->expectObject( + 'createPost.post', + [ + $I->expectField( 'id', Signal::NOT_NULL ), + $I->expectField( 'title', 'Wowwy Zowwy 2' ) + ] + ) + ] + ); + + $I->assertQuerySuccessful( + $responses[2], + [ + $I->expectNode( + 'posts.nodes', + [ + $I->expectField( 'id', Signal::NOT_NULL ), + $I->expectField( 'title', 'Wowwy Zowwy 1' ) + ], + ), + $I->expectNode( + 'posts.nodes', + [ + $I->expectField( 'id', Signal::NOT_NULL ), + $I->expectField( 'title', 'Wowwy Zowwy 2' ) + ] + ) + ] + ); + } + + public function testConcurrentRequests( FunctionalTester $I, $scenario ) { + $I->wantTo( 'send concurrent requests to the GraphQL endpoint and return a response' ); + + $I->haveManyPostsInDatabase(20); + + $operations = [ + [ + 'query' => 'mutation ( $input: CreatePostInput! ) { + createPost( input: $input ) { + post { + id + title + slug + status + } + } + }', + 'variables' => [ + 'input' => [ + 'title' => 'Scream 1', + 'content' => 'Scream 1 content', + 'slug' => 'scream-1', + 'status' => 'PUBLISH', + ] + ] + ], + [ + 'query' => '{ + posts(where: { search: "Scream" }) { + nodes { + id + title + } + } + }' + ], + [ + 'query' => 'mutation ( $input: CreatePostInput! ) { + createPost( input: $input ) { + post { + id + title + } + } + }', + 'variables' => [ + 'input' => [ + 'title' => 'Scream 2', + 'content' => 'Scream 2 content', + 'slug' => 'scream-2', + 'status' => 'PUBLISH', + ] + ] + ], + [ + 'query' => '{ + posts(where: { search: "Scream" }) { + nodes { + id + title + } + } + }' + ], + ]; + + $responses = $I->concurrentRequests( $operations, [], 0 ); + + $I->assertQuerySuccessful( + $responses[0], + [ + $I->expectObject( + 'createPost.post', + [ + $I->expectField( 'id', Signal::NOT_NULL ), + $I->expectField( 'title', 'Scream 1' ) + ] + ) + ] + ); + + $I->assertQuerySuccessful( + $responses[1], + [ + $I->expectField( + 'posts.nodes', + Signal::IS_FALSY + ) + ] + ); + + $I->assertQuerySuccessful( + $responses[2], + [ + $I->expectObject( + 'createPost.post', + [ + $I->expectField( 'id', Signal::NOT_NULL ), + $I->expectField( 'title', 'Scream 2' ) + ] + ) + ] + ); + + $I->assertQuerySuccessful( + $responses[3], + [ + $I->expectField( + 'posts.nodes', + Signal::IS_FALSY + ) + ] + ); + } +} diff --git a/tests/codeception/wpunit/QueryConstraintTest.php b/tests/codeception/wpunit/QueryConstraintTest.php index d5b6104..aff3024 100644 --- a/tests/codeception/wpunit/QueryConstraintTest.php +++ b/tests/codeception/wpunit/QueryConstraintTest.php @@ -84,7 +84,7 @@ public function testFailureDescription() { $constraint = new QueryConstraint($this->logger); $response = [4, 5, 6]; $this->assertFalse($constraint->matches($response)); - $this->assertEquals("GraphQL response failed validation: \n\n\t• The GraphQL query response must be provided as an associative array.", $constraint->failureDescription($response)); + $this->assertEquals("GraphQL response is invalid: \n\n\t• The GraphQL query response must be provided as an associative array.", $constraint->failureDescription($response)); } public function testToString() { diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 9c236f3..13e6715 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -39,5 +39,22 @@ function wpgraphql_testcase_filter_active_plugins_for_phpunit( $active_plugins ) tests_add_filter( 'site_option_active_sitewide_plugins', 'wpgraphql_testcase_filter_active_plugins_for_phpunit' ); tests_add_filter( 'option_active_plugins', 'wpgraphql_testcase_filter_active_plugins_for_phpunit' ); + +function _manually_load_wpgraphql_deps() { + $autoload_file = WP_PLUGIN_DIR . '/wp-graphql/vendor/autoload.php'; + if ( file_exists( $autoload_file ) ) { + require_once $autoload_file; + } + + if ( ! defined( 'WPGRAPHQL_AUTOLOAD' ) ) { + define( 'WPGRAPHQL_AUTOLOAD', false ); + } +} +tests_add_filter( 'muplugins_loaded', '_manually_load_wpgraphql_deps' ); + +//if ( function_exists( 'graphql_can_load_plugin' ) ) { + +//} + // @see https://core.trac.wordpress.org/browser/trunk/tests/phpunit/includes/bootstrap.php require $_tests_dir . '/includes/bootstrap.php'; \ No newline at end of file diff --git a/tests/phpunit/unit/test-queryconstraint.php b/tests/phpunit/unit/test-queryconstraint.php index 151ed3f..27888bf 100644 --- a/tests/phpunit/unit/test-queryconstraint.php +++ b/tests/phpunit/unit/test-queryconstraint.php @@ -84,7 +84,7 @@ public function test_FailureDescription() { $constraint = new QueryConstraint($this->logger); $response = [4, 5, 6]; $this->assertFalse($constraint->matches($response)); - $this->assertEquals("GraphQL response failed validation: \n\n\t• The GraphQL query response must be provided as an associative array.", $constraint->failureDescription($response)); + $this->assertEquals("GraphQL response is invalid: \n\n\t• The GraphQL query response must be provided as an associative array.", $constraint->failureDescription($response)); } public function test_ToString() {