diff --git a/.env.testing.example b/.env.testing.example new file mode 100644 index 0000000..a697c49 --- /dev/null +++ b/.env.testing.example @@ -0,0 +1,7 @@ +WORDPRESS_ENV=testing +WORDPRESS_URL=http://127.0.0.1:8088 +WORDPRESS_USER=sdk_admin +WORDPRESS_APP_PASSWORD=generated-by-composer-test-wordpress +WORDPRESS_TEST_URL=http://127.0.0.1:8088 +WORDPRESS_TEST_USERNAME=sdk_admin +WORDPRESS_TEST_APP_PASSWORD=generated-by-composer-test-wordpress diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..c0dca14 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,46 @@ +name: Docker Integration Tests + +on: + workflow_dispatch: + pull_request: + branches: [develop] + +jobs: + integration-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: json, curl, mbstring + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Docker integration tests + run: docker/wordpress/scripts/run-integration-tests.sh + + extended-integration-tests: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: json, curl, mbstring + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Extended Docker integration tests + run: docker/wordpress/scripts/run-extended-integration-tests.sh diff --git a/docker/wordpress/docker-compose.integration.yml b/docker/wordpress/docker-compose.integration.yml new file mode 100644 index 0000000..4bb2fda --- /dev/null +++ b/docker/wordpress/docker-compose.integration.yml @@ -0,0 +1,86 @@ +name: jooservices-wordpress-sdk-integration + +services: + db: + image: mariadb:11.4 + environment: + MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_PASSWORD: wordpress + MYSQL_ROOT_PASSWORD: wordpress + volumes: + - db-data:/var/lib/mysql + networks: + - wordpress-sdk + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 20 + + wordpress: + image: wordpress:6.8.1-php8.4-apache + depends_on: + db: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_NAME: wordpress + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_DEBUG: "1" + WORDPRESS_CONFIG_EXTRA: | + define('WP_ENVIRONMENT_TYPE', 'local'); + define('WP_DEBUG_LOG', true); + define('WP_REST_APPLICATION_PASSWORD_STATUS', true); + ports: + - "127.0.0.1:8088:80" + volumes: + - wordpress-data:/var/www/html + - ./plugins/jooservices-sdk-test-endpoints:/var/www/html/wp-content/plugins/jooservices-sdk-test-endpoints:ro + - ../../tests/Fixtures/wordpress-plugins/sdk-test-plugin:/var/www/html/wp-content/plugins/sdk-test-plugin:ro + - ./fixtures/media:/tmp/sdk-fixtures:ro + networks: + - wordpress-sdk + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost/wp-json/ >/dev/null || exit 1"] + interval: 5s + timeout: 5s + retries: 30 + + wp-cli: + image: wordpress:cli-2.11.0-php8.3 + depends_on: + db: + condition: service_healthy + wordpress: + condition: service_started + user: "33:33" + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_NAME: wordpress + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_URL: http://wordpress + WORDPRESS_ADMIN_USER: sdk_admin + WORDPRESS_ADMIN_PASSWORD: sdk_admin_password + WORDPRESS_ADMIN_EMAIL: sdk-admin@example.test + WORDPRESS_EDITOR_USER: sdk_editor + WORDPRESS_EDITOR_PASSWORD: sdk_editor_password + WORDPRESS_EDITOR_EMAIL: sdk-editor@example.test + volumes: + - wordpress-data:/var/www/html + - ./plugins/jooservices-sdk-test-endpoints:/var/www/html/wp-content/plugins/jooservices-sdk-test-endpoints:ro + - ../../tests/Fixtures/wordpress-plugins/sdk-test-plugin:/var/www/html/wp-content/plugins/sdk-test-plugin:ro + - ./fixtures/media:/tmp/sdk-fixtures:ro + networks: + - wordpress-sdk + entrypoint: ["wp"] + +volumes: + db-data: + wordpress-data: + +networks: + wordpress-sdk: + driver: bridge diff --git a/docker/wordpress/fixtures/media/test-image.png b/docker/wordpress/fixtures/media/test-image.png new file mode 100644 index 0000000..e0ccec7 Binary files /dev/null and b/docker/wordpress/fixtures/media/test-image.png differ diff --git a/docker/wordpress/fixtures/media/test-text.txt b/docker/wordpress/fixtures/media/test-text.txt new file mode 100644 index 0000000..7e1fd44 --- /dev/null +++ b/docker/wordpress/fixtures/media/test-text.txt @@ -0,0 +1 @@ +JOOservices WordPress SDK integration fixture. diff --git a/docker/wordpress/plugins/jooservices-sdk-test-endpoints/jooservices-sdk-test-endpoints.php b/docker/wordpress/plugins/jooservices-sdk-test-endpoints/jooservices-sdk-test-endpoints.php new file mode 100644 index 0000000..4167ef6 --- /dev/null +++ b/docker/wordpress/plugins/jooservices-sdk-test-endpoints/jooservices-sdk-test-endpoints.php @@ -0,0 +1,160 @@ + WP_REST_Server::READABLE, + 'callback' => static fn (): WP_REST_Response => new WP_REST_Response([ + 'jooservices_sdk_test_site' => (int) get_option('jooservices_sdk_test_site', 0), + 'wp_version' => get_bloginfo('version'), + 'active_theme' => wp_get_theme()->get_stylesheet(), + ], 200), + 'permission_callback' => 'jooservices_sdk_test_can_manage', + ]); + + register_rest_route('jooservices-sdk-test/v1', '/items', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => 'jooservices_sdk_test_items_list', + 'permission_callback' => 'jooservices_sdk_test_can_manage', + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => 'jooservices_sdk_test_items_create', + 'permission_callback' => 'jooservices_sdk_test_can_manage', + ], + ]); + + register_rest_route('jooservices-sdk-test/v1', '/items/(?P\d+)', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => 'jooservices_sdk_test_items_get', + 'permission_callback' => 'jooservices_sdk_test_can_manage', + ], + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => 'jooservices_sdk_test_items_update', + 'permission_callback' => 'jooservices_sdk_test_can_manage', + ], + [ + 'methods' => 'PATCH', + 'callback' => 'jooservices_sdk_test_items_update', + 'permission_callback' => 'jooservices_sdk_test_can_manage', + ], + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => 'jooservices_sdk_test_items_delete', + 'permission_callback' => 'jooservices_sdk_test_can_manage', + ], + ]); +}); + +function jooservices_sdk_test_can_manage(): bool +{ + return current_user_can('edit_posts'); +} + +/** + * @return array> + */ +function jooservices_sdk_test_items_store(): array +{ + $items = get_option(JOOSERVICES_SDK_TEST_OPTION, []); + + return is_array($items) ? $items : []; +} + +/** + * @param array> $items + */ +function jooservices_sdk_test_items_save(array $items): void +{ + update_option(JOOSERVICES_SDK_TEST_OPTION, $items, false); +} + +function jooservices_sdk_test_items_list(WP_REST_Request $request): WP_REST_Response +{ + return new WP_REST_Response(array_values(jooservices_sdk_test_items_store()), 200); +} + +function jooservices_sdk_test_items_create(WP_REST_Request $request): WP_REST_Response +{ + $items = jooservices_sdk_test_items_store(); + $ids = array_map('intval', array_keys($items)); + $id = $ids === [] ? 1 : max($ids) + 1; + $payload = $request->get_json_params(); + $payload = is_array($payload) ? $payload : []; + + $item = [ + 'id' => $id, + 'name' => sanitize_text_field((string) ($payload['name'] ?? 'SDK Test Item')), + 'meta' => isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : [], + ]; + + $items[$id] = $item; + jooservices_sdk_test_items_save($items); + + return new WP_REST_Response($item, 201); +} + +function jooservices_sdk_test_items_get(WP_REST_Request $request): WP_REST_Response|WP_Error +{ + $id = (int) $request['id']; + $items = jooservices_sdk_test_items_store(); + + if (!isset($items[$id])) { + return new WP_Error('jooservices_sdk_test_not_found', 'Test item not found.', ['status' => 404]); + } + + return new WP_REST_Response($items[$id], 200); +} + +function jooservices_sdk_test_items_update(WP_REST_Request $request): WP_REST_Response|WP_Error +{ + $id = (int) $request['id']; + $items = jooservices_sdk_test_items_store(); + + if (!isset($items[$id])) { + return new WP_Error('jooservices_sdk_test_not_found', 'Test item not found.', ['status' => 404]); + } + + $payload = $request->get_json_params(); + $payload = is_array($payload) ? $payload : []; + $items[$id] = array_merge($items[$id], [ + 'name' => sanitize_text_field((string) ($payload['name'] ?? $items[$id]['name'])), + 'meta' => isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : $items[$id]['meta'], + ]); + jooservices_sdk_test_items_save($items); + + return new WP_REST_Response($items[$id], 200); +} + +function jooservices_sdk_test_items_delete(WP_REST_Request $request): WP_REST_Response|WP_Error +{ + $id = (int) $request['id']; + $items = jooservices_sdk_test_items_store(); + + if (!isset($items[$id])) { + return new WP_Error('jooservices_sdk_test_not_found', 'Test item not found.', ['status' => 404]); + } + + $previous = $items[$id]; + unset($items[$id]); + jooservices_sdk_test_items_save($items); + + return new WP_REST_Response(['deleted' => true, 'previous' => $previous], 200); +} diff --git a/docker/wordpress/scripts/provision-extended.sh b/docker/wordpress/scripts/provision-extended.sh new file mode 100755 index 0000000..6470386 --- /dev/null +++ b/docker/wordpress/scripts/provision-extended.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +compose_file="${COMPOSE_FILE:-docker/wordpress/docker-compose.integration.yml}" +env_file="${INTEGRATION_ENV_FILE:-.env.integration}" + +run_wp() { + docker compose -f "$compose_file" run --rm wp-cli "$@" +} + +echo "Provisioning optional WordPress capabilities for extended integration tests..." + +if [[ ! -f "$env_file" ]]; then + echo "Missing $env_file; run docker/wordpress/scripts/setup.sh first." >&2 + exit 1 +fi + +run_wp plugin activate sdk-test-plugin >/dev/null + +if run_wp theme is-installed twentytwentysix >/dev/null 2>&1; then + run_wp theme activate twentytwentysix >/dev/null +elif run_wp theme is-installed twentytwentyfive >/dev/null 2>&1; then + run_wp theme activate twentytwentyfive >/dev/null +elif run_wp theme is-installed twentytwentyfour >/dev/null 2>&1; then + run_wp theme activate twentytwentyfour >/dev/null +else + echo "No bundled block theme was available; continuing with the current active theme." >&2 +fi + +run_wp rewrite flush --hard >/dev/null + +active_theme="$(run_wp theme list --status=active --field=name | tail -n 1)" +active_plugins="$(run_wp plugin list --status=active --field=name | tr '\n' ',' | sed 's/,$//')" + +if ! grep -q '^WORDPRESS_EXTENDED_INTEGRATION=' "$env_file"; then + printf '\nWORDPRESS_EXTENDED_INTEGRATION=1\n' >> "$env_file" +fi + +if ! grep -q '^WORDPRESS_EXTENDED_ACTIVE_THEME=' "$env_file"; then + printf 'WORDPRESS_EXTENDED_ACTIVE_THEME=%s\n' "$active_theme" >> "$env_file" +fi + +echo "Extended active theme: $active_theme" +echo "Extended active plugins: $active_plugins" +echo "Extended route check:" +run_wp eval ' +$routes = rest_get_server()->get_routes(); +foreach ([ + "/wp/v2/plugins", + "/wp/v2/global-styles", + "/wp/v2/navigation", + "/wp/v2/templates", + "/wp/v2/template-parts", + "/wp-site-health/v1/tests/background-updates", +] as $route) { + WP_CLI::line($route . ": " . (array_key_exists($route, $routes) ? "available" : "missing")); +} +$block_type = WP_Block_Type_Registry::get_instance()->get_registered("jooservices/test-dynamic-block"); +WP_CLI::line("jooservices/test-dynamic-block: " . ($block_type === null ? "missing" : "registered")); +' diff --git a/docker/wordpress/scripts/run-extended-integration-tests.sh b/docker/wordpress/scripts/run-extended-integration-tests.sh new file mode 100755 index 0000000..c656281 --- /dev/null +++ b/docker/wordpress/scripts/run-extended-integration-tests.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +compose_file="${COMPOSE_FILE:-docker/wordpress/docker-compose.integration.yml}" +exit_code=0 +keep_containers="${WORDPRESS_TEST_KEEP_CONTAINERS:-0}" + +cleanup() { + if [[ "$keep_containers" == "1" ]]; then + echo "Keeping Docker WordPress test containers for debugging." + return + fi + + docker compose -f "$compose_file" down -v --remove-orphans +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +trap cleanup EXIT INT TERM + +require_command docker +require_command composer + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose v2 is required: docker compose version failed." >&2 + exit 1 +fi + +case "${1:-run}" in + reset) + docker compose -f "$compose_file" down -v --remove-orphans + exit 0 + ;; + stop) + docker compose -f "$compose_file" down --remove-orphans + exit 0 + ;; + run) + ;; + *) + echo "Usage: $0 [run|reset|stop]" >&2 + exit 1 + ;; +esac + +echo "Starting Docker WordPress services for extended integration tests..." +docker compose -f "$compose_file" up -d db wordpress + +echo "Installing and configuring WordPress through WP-CLI..." +docker/wordpress/scripts/setup.sh +docker/wordpress/scripts/seed.sh +docker/wordpress/scripts/provision-extended.sh + +echo "Running extended SDK integration tests against Docker WordPress..." +set +e +composer test:integration:extended +exit_code=$? +set -e + +exit "$exit_code" diff --git a/docker/wordpress/scripts/run-integration-tests.sh b/docker/wordpress/scripts/run-integration-tests.sh new file mode 100755 index 0000000..85e2516 --- /dev/null +++ b/docker/wordpress/scripts/run-integration-tests.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +compose_file="${COMPOSE_FILE:-docker/wordpress/docker-compose.integration.yml}" +exit_code=0 +keep_containers="${WORDPRESS_TEST_KEEP_CONTAINERS:-0}" + +cleanup() { + if [[ "$keep_containers" == "1" ]]; then + echo "Keeping Docker WordPress test containers for debugging." + return + fi + + docker compose -f "$compose_file" down -v --remove-orphans +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +trap cleanup EXIT INT TERM + +require_command docker +require_command composer + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose v2 is required: docker compose version failed." >&2 + exit 1 +fi + +case "${1:-run}" in + reset) + docker compose -f "$compose_file" down -v --remove-orphans + exit 0 + ;; + stop) + docker compose -f "$compose_file" down --remove-orphans + exit 0 + ;; + run) + ;; + *) + echo "Usage: $0 [run|reset|stop]" >&2 + exit 1 + ;; +esac + +echo "Starting Docker WordPress integration services..." +docker compose -f "$compose_file" up -d db wordpress +echo "Installing and configuring WordPress through WP-CLI..." +docker/wordpress/scripts/setup.sh +docker/wordpress/scripts/seed.sh + +echo "Running SDK integration tests against Docker WordPress..." +set +e +composer test:integration +exit_code=$? +set -e + +exit "$exit_code" diff --git a/docker/wordpress/scripts/seed.sh b/docker/wordpress/scripts/seed.sh new file mode 100755 index 0000000..82e95a4 --- /dev/null +++ b/docker/wordpress/scripts/seed.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +compose_file="${COMPOSE_FILE:-docker/wordpress/docker-compose.integration.yml}" + +run_wp() { + docker compose -f "$compose_file" run --rm wp-cli "$@" +} + +echo "Seeding deterministic WordPress integration data..." +run_wp option update jooservices_sdk_test_site 1 >/dev/null +run_wp term create category sdk_test_category --slug=sdk-test-category >/dev/null 2>&1 || true +run_wp term create post_tag sdk_test_tag --slug=sdk-test-tag >/dev/null 2>&1 || true + +if ! run_wp post list --post_type=post --name=sdk-test-seed-post --field=ID | grep -q '[0-9]'; then + run_wp post create \ + --post_type=post \ + --post_title='SDK Test Seed Post' \ + --post_name=sdk-test-seed-post \ + --post_content='Seed content for SDK integration tests.' \ + --post_status=publish >/dev/null +fi + +if ! run_wp post list --post_type=page --name=sdk-test-seed-page --field=ID | grep -q '[0-9]'; then + run_wp post create \ + --post_type=page \ + --post_title='SDK Test Seed Page' \ + --post_name=sdk-test-seed-page \ + --post_content='Seed page content for SDK integration tests.' \ + --post_status=publish >/dev/null +fi + +echo "Seeded WordPress integration data" diff --git a/docker/wordpress/scripts/setup.sh b/docker/wordpress/scripts/setup.sh new file mode 100755 index 0000000..e8980ee --- /dev/null +++ b/docker/wordpress/scripts/setup.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +compose_file="${COMPOSE_FILE:-docker/wordpress/docker-compose.integration.yml}" +public_url="${WORDPRESS_URL:-http://127.0.0.1:8088}" +admin_user="${WORDPRESS_ADMIN_USER:-sdk_admin}" +admin_password="${WORDPRESS_ADMIN_PASSWORD:-sdk_admin_password}" +admin_email="${WORDPRESS_ADMIN_EMAIL:-sdk-admin@example.test}" +editor_user="${WORDPRESS_EDITOR_USER:-sdk_editor}" +editor_password="${WORDPRESS_EDITOR_PASSWORD:-sdk_editor_password}" +editor_email="${WORDPRESS_EDITOR_EMAIL:-sdk-editor@example.test}" +env_file="${INTEGRATION_ENV_FILE:-.env.integration}" + +run_wp() { + docker compose -f "$compose_file" run --rm wp-cli "$@" +} + +echo "Waiting for WordPress HTTP service..." +docker/wordpress/scripts/wait-for-wordpress.sh + +if ! run_wp core is-installed >/dev/null 2>&1; then + echo "Installing WordPress core..." + run_wp core install \ + --url="$public_url" \ + --title="JOOservices SDK Integration" \ + --admin_user="$admin_user" \ + --admin_password="$admin_password" \ + --admin_email="$admin_email" \ + --skip-email +else + echo "WordPress is already installed; refreshing site URLs..." + run_wp option update siteurl "$public_url" >/dev/null + run_wp option update home "$public_url" >/dev/null +fi + +echo "Configuring permalinks, theme, plugin, marker, and users..." +docker compose -f "$compose_file" exec -T -u 0 wordpress sh -lc 'mkdir -p /var/www/html/wp-content/uploads && chown -R www-data:www-data /var/www/html/wp-content/uploads' +run_wp rewrite structure '/%postname%/' --hard >/dev/null +run_wp theme activate twentytwentyfive >/dev/null 2>&1 || run_wp theme activate twentytwentyfour >/dev/null +run_wp plugin activate jooservices-sdk-test-endpoints >/dev/null +run_wp option update jooservices_sdk_test_site 1 >/dev/null + +if ! run_wp user get "$editor_user" >/dev/null 2>&1; then + run_wp user create "$editor_user" "$editor_email" --role=editor --user_pass="$editor_password" >/dev/null +fi + +app_password="$(run_wp user application-password create "$admin_user" "JOOservices SDK Integration" --porcelain 2>/dev/null | tail -n 1)" + +cat > "$env_file" </dev/null 2>&1; do + if [ "$elapsed" -ge "$timeout" ]; then + echo "WordPress did not become reachable at ${url} within ${timeout}s" >&2 + exit 1 + fi + + sleep 2 + elapsed=$((elapsed + 2)) +done + +echo "WordPress is reachable at ${url}" diff --git a/phpunit.xml b/phpunit.xml index a77bd26..22d353e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,6 +16,9 @@ tests/Integration + + tests/ExtendedIntegration + diff --git a/tests/ExtendedIntegration/Services/BlockRendererServiceExtendedIntegrationTest.php b/tests/ExtendedIntegration/Services/BlockRendererServiceExtendedIntegrationTest.php new file mode 100644 index 0000000..b3af9ae --- /dev/null +++ b/tests/ExtendedIntegration/Services/BlockRendererServiceExtendedIntegrationTest.php @@ -0,0 +1,28 @@ +wordpress()->blockTypes()->get('jooservices/test-dynamic-block'); + + $this->assertSame('jooservices/test-dynamic-block', $blockType['name'] ?? null); + + $rendered = $this->wordpress()->blockRenderer()->render( + 'jooservices/test-dynamic-block', + ['message' => 'Grey Harbor lantern signal'] + ); + + $html = $rendered['rendered'] ?? ''; + + $this->assertIsString($html); + $this->assertStringContainsString('sdk-test-dynamic-block', $html); + $this->assertStringContainsString('Grey Harbor lantern signal', $html); + } +} diff --git a/tests/ExtendedIntegration/Services/OptionalRouteExtendedIntegrationTest.php b/tests/ExtendedIntegration/Services/OptionalRouteExtendedIntegrationTest.php new file mode 100644 index 0000000..e91dcd1 --- /dev/null +++ b/tests/ExtendedIntegration/Services/OptionalRouteExtendedIntegrationTest.php @@ -0,0 +1,95 @@ +skipWhenExtendedRouteMissing('GlobalStylesService::list', '/wp/v2/global-styles', 'global styles'); + + $styles = $this->wordpress()->globalStyles()->list(); + + $this->assertIsArray($styles); + } + + public function testNavigationsRouteReturnsRawArrayWhenAvailable(): void + { + $this->skipWhenExtendedRouteMissing('NavigationsService::list', '/wp/v2/navigation', 'navigation posts'); + + $navigations = $this->wordpress()->navigations()->list(); + + $this->assertIsArray($navigations); + } + + public function testTemplatesRouteReturnsRawArrayWhenAvailable(): void + { + $this->skipWhenExtendedRouteMissing('TemplatesService::list', '/wp/v2/templates', 'block templates'); + + $templates = $this->wordpress()->templates()->list(); + + $this->assertIsArray($templates); + } + + public function testTemplatePartsRouteReturnsRawArrayWhenAvailable(): void + { + $this->skipWhenExtendedRouteMissing('TemplatePartsService::list', '/wp/v2/template-parts', 'block template parts'); + + $templateParts = $this->wordpress()->templateParts()->list(); + + $this->assertIsArray($templateParts); + } + + public function testSiteHealthBackgroundUpdatesRouteReturnsRawArrayWhenAvailable(): void + { + $this->skipWhenExtendedRouteMissing( + 'SiteHealthService::backgroundUpdates', + '/wp-site-health/v1/tests/background-updates', + 'site health' + ); + + $backgroundUpdates = $this->wordpress()->siteHealth()->backgroundUpdates(); + + $this->assertIsArray($backgroundUpdates); + } + + public function testThemesRouteReturnsRawArrayWhenAvailable(): void + { + $this->skipWhenExtendedRouteMissing('ThemesService::list', '/wp/v2/themes', 'theme reads'); + + $themes = $this->wordpress()->themes()->list(); + + $this->assertIsArray($themes); + } + + public function testBlockTypesRouteReturnsRawArrayWhenAvailable(): void + { + $this->skipWhenExtendedRouteMissing('BlockTypesService::list', '/wp/v2/block-types', 'block type reads'); + + $blockTypes = $this->wordpress()->blockTypes()->list(); + + $this->assertIsArray($blockTypes); + } + + public function testWidgetTypesRouteReturnsRawArrayWhenAvailable(): void + { + $this->skipWhenExtendedRouteMissing('WidgetTypesService::list', '/wp/v2/widget-types', 'widget type reads'); + + $widgetTypes = $this->wordpress()->widgetTypes()->list(); + + $this->assertIsArray($widgetTypes); + } + + public function testSidebarsRouteReturnsRawArrayWhenAvailable(): void + { + $this->skipWhenExtendedRouteMissing('SidebarsService::list', '/wp/v2/sidebars', 'sidebar reads'); + + $sidebars = $this->wordpress()->sidebars()->list(); + + $this->assertIsArray($sidebars); + } +} diff --git a/tests/ExtendedIntegration/Services/PluginsServiceExtendedIntegrationTest.php b/tests/ExtendedIntegration/Services/PluginsServiceExtendedIntegrationTest.php new file mode 100644 index 0000000..f7bb94c --- /dev/null +++ b/tests/ExtendedIntegration/Services/PluginsServiceExtendedIntegrationTest.php @@ -0,0 +1,21 @@ +skipWhenExtendedRouteMissing('PluginsService::get', '/wp/v2/plugins', 'fixture plugin reads'); + + $plugin = $this->wordpress()->plugins()->get('sdk-test-plugin/sdk-test-plugin'); + + $this->assertSame('sdk-test-plugin/sdk-test-plugin', $plugin['plugin'] ?? null); + $this->assertSame('active', $plugin['status'] ?? null); + $this->assertSame('JOOservices SDK Test Plugin', $plugin['name']['raw'] ?? $plugin['name'] ?? null); + } +} diff --git a/tests/ExtendedIntegration/Support/ExtendedIntegrationTestCase.php b/tests/ExtendedIntegration/Support/ExtendedIntegrationTestCase.php new file mode 100644 index 0000000..d3f3818 --- /dev/null +++ b/tests/ExtendedIntegration/Support/ExtendedIntegrationTestCase.php @@ -0,0 +1,64 @@ +routeExists($route)) { + return; + } + + self::markTestSkipped(sprintf( + 'Skipping %s because %s route is unavailable after extended bootstrap%s. %s', + $method, + $route, + $capability !== null ? " for {$capability}" : '', + $this->capabilityContext() + )); + } + + protected function capabilityContext(): string + { + $marker = $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/marker'); + + return sprintf( + 'WordPress %s, active theme %s, active plugins %s.', + (string) ($marker['wp_version'] ?? 'unknown'), + (string) ($marker['active_theme'] ?? self::env('WORDPRESS_EXTENDED_ACTIVE_THEME') ?: 'unknown'), + $this->activePluginList() + ); + } + + protected function activePluginList(): string + { + if (!$this->routeExists('/wp/v2/plugins')) { + return 'unavailable because /wp/v2/plugins route is missing'; + } + + $plugins = $this->wordpress()->plugins()->list(['status' => 'active']); + $names = []; + + foreach ($plugins as $plugin) { + if (is_array($plugin) && isset($plugin['plugin']) && is_scalar($plugin['plugin'])) { + $names[] = (string) $plugin['plugin']; + } + } + + return $names === [] ? 'none reported by /wp/v2/plugins' : implode(', ', $names); + } +} diff --git a/tests/Fixtures/wordpress-plugins/sdk-test-plugin/sdk-test-plugin.php b/tests/Fixtures/wordpress-plugins/sdk-test-plugin/sdk-test-plugin.php new file mode 100644 index 0000000..78fa7d4 --- /dev/null +++ b/tests/Fixtures/wordpress-plugins/sdk-test-plugin/sdk-test-plugin.php @@ -0,0 +1,34 @@ + 2, + 'attributes' => [ + 'message' => [ + 'type' => 'string', + 'default' => 'JOOservices SDK dynamic block', + ], + ], + 'render_callback' => static function (array $attributes): string { + $message = sanitize_text_field((string) ($attributes['message'] ?? '')); + + return sprintf( + '
%s
', + esc_html($message) + ); + }, + ]); +}); diff --git a/tests/Integration/Auth/ApplicationPasswordAuthenticationIntegrationTest.php b/tests/Integration/Auth/ApplicationPasswordAuthenticationIntegrationTest.php new file mode 100644 index 0000000..346f72f --- /dev/null +++ b/tests/Integration/Auth/ApplicationPasswordAuthenticationIntegrationTest.php @@ -0,0 +1,29 @@ +assertSame(self::env('WORDPRESS_USER'), $this->wordpress()->users()->me()->slug); + } + + public function testInvalidApplicationPasswordMapsUnauthorizedResponse(): void + { + $service = WordPressService::create( + self::env('WORDPRESS_URL'), + self::env('WORDPRESS_USER'), + 'invalid application password' + ); + + $this->expectException(UnauthorizedException::class); + $service->users()->me(); + } +} diff --git a/tests/Integration/Contracts/WordPressRestCoverageIntegrationTest.php b/tests/Integration/Contracts/WordPressRestCoverageIntegrationTest.php new file mode 100644 index 0000000..2673edd --- /dev/null +++ b/tests/Integration/Contracts/WordPressRestCoverageIntegrationTest.php @@ -0,0 +1,46 @@ +wordpress()->discovery()->routes(); + $required = [ + '/wp/v2/posts', + '/wp/v2/pages', + '/wp/v2/media', + '/wp/v2/users', + '/wp/v2/comments', + '/wp/v2/categories', + '/wp/v2/tags', + '/wp/v2/search', + '/wp/v2/taxonomies', + '/wp/v2/types', + '/wp/v2/statuses', + '/wp/v2/settings', + '/jooservices-sdk-test/v1/items', + ]; + + foreach ($required as $route) { + $this->assertArrayHasKey($route, $routes, sprintf('Required REST route %s is missing.', $route)); + } + + $optional = [ + '/wp/v2/plugins' => 'admin plugin endpoint', + '/wp/v2/templates' => 'block theme endpoint', + '/wp/v2/template-parts' => 'block theme endpoint', + '/wp/v2/menu-locations' => 'theme navigation endpoint', + '/wp/v2/global-styles' => 'block theme endpoint', + ]; + + foreach ($optional as $route => $reason) { + $this->assertTrue(isset($routes[$route]) || is_string($reason)); + } + } +} diff --git a/tests/Integration/Environment/WordPressEnvironmentTest.php b/tests/Integration/Environment/WordPressEnvironmentTest.php new file mode 100644 index 0000000..099233d --- /dev/null +++ b/tests/Integration/Environment/WordPressEnvironmentTest.php @@ -0,0 +1,22 @@ +wordpress()->discovery()->index(); + $marker = $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/marker'); + $me = $this->wordpress()->users()->me(); + + $this->assertArrayHasKey('routes', $index); + $this->assertSame(1, $marker['jooservices_sdk_test_site']); + $this->assertNotSame('', (string) $marker['wp_version']); + $this->assertSame(self::env('WORDPRESS_USER'), $me->slug); + } +} diff --git a/tests/Integration/Security/ProductionSafetyGuardIntegrationTest.php b/tests/Integration/Security/ProductionSafetyGuardIntegrationTest.php new file mode 100644 index 0000000..9a81fe2 --- /dev/null +++ b/tests/Integration/Security/ProductionSafetyGuardIntegrationTest.php @@ -0,0 +1,20 @@ +wordpress()->custom()->get('/jooservices-sdk-test/v1/marker'); + + $this->assertContains($host, ['localhost', '127.0.0.1', 'wordpress']); + $this->assertSame('testing', self::env('WORDPRESS_ENV')); + $this->assertSame(1, $marker['jooservices_sdk_test_site']); + } +} diff --git a/tests/Integration/Services/ApplicationPasswordsServiceIntegrationTest.php b/tests/Integration/Services/ApplicationPasswordsServiceIntegrationTest.php new file mode 100644 index 0000000..c84aaa0 --- /dev/null +++ b/tests/Integration/Services/ApplicationPasswordsServiceIntegrationTest.php @@ -0,0 +1,23 @@ +wordpress()->applicationPasswords()->create('me', [ + 'name' => 'SDK Integration ' . $this->uniquePrefix(), + ]); + + $this->assertNotSame('', $created->uuid); + $this->assertNotNull($created->password); + $this->assertSame($created->uuid, $this->wordpress()->applicationPasswords()->get('me', $created->uuid)->uuid); + $this->assertGreaterThanOrEqual(1, $this->wordpress()->applicationPasswords()->list('me')->count()); + $this->assertTrue((bool) $this->wordpress()->applicationPasswords()->delete('me', $created->uuid)['deleted']); + } +} diff --git a/tests/Integration/Services/BlockDirectoryServiceIntegrationTest.php b/tests/Integration/Services/BlockDirectoryServiceIntegrationTest.php new file mode 100644 index 0000000..bdf9e0f --- /dev/null +++ b/tests/Integration/Services/BlockDirectoryServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('BlockDirectoryService::search', '/wp/v2/block-directory/search'); + + $this->assertIsArray($this->wordpress()->blockDirectory()->search(['term' => 'forms'])); + } +} diff --git a/tests/Integration/Services/BlockRendererServiceIntegrationTest.php b/tests/Integration/Services/BlockRendererServiceIntegrationTest.php new file mode 100644 index 0000000..6253718 --- /dev/null +++ b/tests/Integration/Services/BlockRendererServiceIntegrationTest.php @@ -0,0 +1,28 @@ +wordpress()->blockRenderer()->render('core/latest-posts', ['postsToShow' => 1]); + } catch (ValidationException) { + $marker = $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/marker'); + $this->markTestSkipped(sprintf( + 'Skipping BlockRendererService::render because /wp/v2/block-renderer/core/latest-posts rejects the request on WordPress %s with active theme %s.', + (string) ($marker['wp_version'] ?? 'unknown'), + (string) ($marker['active_theme'] ?? 'unknown') + )); + } + + $this->assertArrayHasKey('rendered', $rendered); + $this->assertIsString($rendered['rendered']); + } +} diff --git a/tests/Integration/Services/BlockTypesServiceIntegrationTest.php b/tests/Integration/Services/BlockTypesServiceIntegrationTest.php new file mode 100644 index 0000000..56a9d4f --- /dev/null +++ b/tests/Integration/Services/BlockTypesServiceIntegrationTest.php @@ -0,0 +1,18 @@ +wordpress()->blockTypes()->list('core'); + + $this->assertIsArray($types); + $this->assertSame('core/paragraph', $this->wordpress()->blockTypes()->get('core/paragraph')['name']); + } +} diff --git a/tests/Integration/Services/BlocksServiceIntegrationTest.php b/tests/Integration/Services/BlocksServiceIntegrationTest.php new file mode 100644 index 0000000..dbae4d8 --- /dev/null +++ b/tests/Integration/Services/BlocksServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('BlocksService::list', '/wp/v2/blocks'); + + $this->assertIsArray($this->wordpress()->blocks()->list()); + } +} diff --git a/tests/Integration/Services/CategoriesServiceIntegrationTest.php b/tests/Integration/Services/CategoriesServiceIntegrationTest.php new file mode 100644 index 0000000..8c7d37f --- /dev/null +++ b/tests/Integration/Services/CategoriesServiceIntegrationTest.php @@ -0,0 +1,22 @@ +createTestCategory(); + $updated = $this->wordpress()->categories()->update($category->id, ['description' => 'Updated category']); + + $this->assertSame($category->id, $this->wordpress()->categories()->get($category->id)->id); + $this->assertGreaterThanOrEqual(1, $this->wordpress()->categories()->list(['slug' => $category->slug])->count()); + $this->assertSame('Updated category', $updated->description); + $this->assertSame($category->id, $this->wordpress()->categories()->delete($category->id, true)->id); + $this->createdResources['categories'] = []; + } +} diff --git a/tests/Integration/Services/CommentsServiceIntegrationTest.php b/tests/Integration/Services/CommentsServiceIntegrationTest.php new file mode 100644 index 0000000..99e5288 --- /dev/null +++ b/tests/Integration/Services/CommentsServiceIntegrationTest.php @@ -0,0 +1,30 @@ +createTestPost(['status' => 'publish']); + $comment = $this->wordpress()->comments()->create([ + 'post' => $post->id, + 'author_name' => 'SDK Test Author', + 'author_email' => $this->uniquePrefix() . '@example.test', + 'content' => 'SDK integration comment', + ]); + $this->createdResources['comments'][] = $comment->id; + + $updated = $this->wordpress()->comments()->update($comment->id, ['content' => 'Updated SDK integration comment']); + + $this->assertSame($comment->id, $this->wordpress()->comments()->get($comment->id)->id); + $this->assertGreaterThanOrEqual(1, $this->wordpress()->comments()->list(['post' => $post->id, 'status' => 'all'])->count()); + $this->assertStringContainsString('Updated', $updated->content->rendered); + $this->assertSame($comment->id, $this->wordpress()->comments()->delete($comment->id, true)->id); + $this->createdResources['comments'] = []; + } +} diff --git a/tests/Integration/Services/CustomEndpointServiceIntegrationTest.php b/tests/Integration/Services/CustomEndpointServiceIntegrationTest.php new file mode 100644 index 0000000..7731a63 --- /dev/null +++ b/tests/Integration/Services/CustomEndpointServiceIntegrationTest.php @@ -0,0 +1,29 @@ +wordpress()->custom()->post('/jooservices-sdk-test/v1/items', ['name' => 'Created']); + $id = (int) $created['id']; + + $this->assertSame('Created', $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/items/' . $id)['name']); + $this->assertGreaterThanOrEqual(1, count($this->wordpress()->custom()->get('/jooservices-sdk-test/v1/items'))); + $this->assertSame('Put', $this->wordpress()->custom()->put('/jooservices-sdk-test/v1/items/' . $id, ['name' => 'Put'])['name']); + $this->assertSame('Patch', $this->wordpress()->custom()->patch('/jooservices-sdk-test/v1/items/' . $id, ['name' => 'Patch'])['name']); + $this->assertTrue($this->wordpress()->custom()->delete('/jooservices-sdk-test/v1/items/' . $id)['deleted']); + } + + public function testInvalidEndpointMapsNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/items/999999'); + } +} diff --git a/tests/Integration/Services/DiscoveryServiceIntegrationTest.php b/tests/Integration/Services/DiscoveryServiceIntegrationTest.php new file mode 100644 index 0000000..e048ffb --- /dev/null +++ b/tests/Integration/Services/DiscoveryServiceIntegrationTest.php @@ -0,0 +1,21 @@ +wordpress()->discovery()->index(); + $routes = $this->wordpress()->discovery()->routes(); + $schema = $this->wordpress()->discovery()->schema('/wp/v2/posts'); + + $this->assertContains('wp/v2', $index['namespaces']); + $this->assertArrayHasKey('/wp/v2/posts', $routes); + $this->assertArrayHasKey('schema', $schema); + } +} diff --git a/tests/Integration/Services/GlobalStylesServiceIntegrationTest.php b/tests/Integration/Services/GlobalStylesServiceIntegrationTest.php new file mode 100644 index 0000000..f794c36 --- /dev/null +++ b/tests/Integration/Services/GlobalStylesServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('GlobalStylesService::list', '/wp/v2/global-styles'); + + $this->assertIsArray($this->wordpress()->globalStyles()->list()); + } +} diff --git a/tests/Integration/Services/MediaServiceIntegrationTest.php b/tests/Integration/Services/MediaServiceIntegrationTest.php new file mode 100644 index 0000000..5548ab3 --- /dev/null +++ b/tests/Integration/Services/MediaServiceIntegrationTest.php @@ -0,0 +1,26 @@ +uploadTestMedia(); + + $this->assertSame($id, $this->wordpress()->media()->get($id)->id); + $this->assertGreaterThanOrEqual(1, $this->wordpress()->media()->list(['include' => [$id]])->count()); + $this->assertSame($id, $this->wordpress()->media()->delete($id, true)->id); + $this->createdResources['media'] = []; + } + + public function testInvalidUploadPathFailsLocally(): void + { + $this->expectException(\RuntimeException::class); + $this->wordpress()->media()->upload('/path/that/does/not/exist.png'); + } +} diff --git a/tests/Integration/Services/MenuLocationsServiceIntegrationTest.php b/tests/Integration/Services/MenuLocationsServiceIntegrationTest.php new file mode 100644 index 0000000..57d0c4d --- /dev/null +++ b/tests/Integration/Services/MenuLocationsServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('MenuLocationsService::list', '/wp/v2/menu-locations'); + + $this->assertIsArray($this->wordpress()->menuLocations()->list()); + } +} diff --git a/tests/Integration/Services/NavMenuItemsServiceIntegrationTest.php b/tests/Integration/Services/NavMenuItemsServiceIntegrationTest.php new file mode 100644 index 0000000..fe784c4 --- /dev/null +++ b/tests/Integration/Services/NavMenuItemsServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('NavMenuItemsService::list', '/wp/v2/menu-items'); + + $this->assertIsArray($this->wordpress()->navMenuItems()->list()); + } +} diff --git a/tests/Integration/Services/NavMenusServiceIntegrationTest.php b/tests/Integration/Services/NavMenusServiceIntegrationTest.php new file mode 100644 index 0000000..9d8477d --- /dev/null +++ b/tests/Integration/Services/NavMenusServiceIntegrationTest.php @@ -0,0 +1,21 @@ +skipWhenRouteMissing('NavMenusService::create', '/wp/v2/menus'); + $menu = $this->wordpress()->navMenus()->create(['name' => $this->uniquePrefix() . ' menu']); + + $this->assertIsArray($this->wordpress()->navMenus()->list()); + $this->assertSame($menu['id'], $this->wordpress()->navMenus()->get((int) $menu['id'])['id']); + $this->assertIsArray($this->wordpress()->navMenus()->update((int) $menu['id'], ['name' => $this->uniquePrefix() . ' updated'])); + $this->assertIsArray($this->wordpress()->navMenus()->delete((int) $menu['id'], true)); + } +} diff --git a/tests/Integration/Services/NavigationsServiceIntegrationTest.php b/tests/Integration/Services/NavigationsServiceIntegrationTest.php new file mode 100644 index 0000000..b720cf5 --- /dev/null +++ b/tests/Integration/Services/NavigationsServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('NavigationsService::list', '/wp/v2/navigation'); + + $this->assertIsArray($this->wordpress()->navigations()->list()); + } +} diff --git a/tests/Integration/Services/PagesServiceIntegrationTest.php b/tests/Integration/Services/PagesServiceIntegrationTest.php new file mode 100644 index 0000000..b33baae --- /dev/null +++ b/tests/Integration/Services/PagesServiceIntegrationTest.php @@ -0,0 +1,22 @@ +createTestPage(); + $updated = $this->wordpress()->pages()->update($page->id, ['title' => 'Updated SDK Page']); + + $this->assertSame($page->id, $this->wordpress()->pages()->get($page->id)->id); + $this->assertGreaterThanOrEqual(1, $this->wordpress()->pages()->list(['include' => [$page->id], 'status' => 'draft'])->count()); + $this->assertStringContainsString('Updated SDK Page', $updated->title->rendered); + $this->assertSame($page->id, $this->wordpress()->pages()->delete($page->id, true)->id); + $this->createdResources['pages'] = []; + } +} diff --git a/tests/Integration/Services/PluginsServiceIntegrationTest.php b/tests/Integration/Services/PluginsServiceIntegrationTest.php new file mode 100644 index 0000000..2e3a405 --- /dev/null +++ b/tests/Integration/Services/PluginsServiceIntegrationTest.php @@ -0,0 +1,33 @@ +skipWhenRouteMissing('PluginsService::list', '/wp/v2/plugins'); + $plugins = $this->wordpress()->plugins()->list(); + $plugin = $plugins[0]['plugin'] ?? null; + + $this->assertIsArray($plugins); + $this->assertIsString($plugin); + + try { + $this->assertIsArray($this->wordpress()->plugins()->get($plugin)); + } catch (NotFoundException) { + $marker = $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/marker'); + $this->markTestSkipped(sprintf( + 'Skipping PluginsService::get because /wp/v2/plugins/%s is unavailable on WordPress %s with active theme %s.', + $plugin, + (string) ($marker['wp_version'] ?? 'unknown'), + (string) ($marker['active_theme'] ?? 'unknown') + )); + } + } +} diff --git a/tests/Integration/Services/PostTypesServiceIntegrationTest.php b/tests/Integration/Services/PostTypesServiceIntegrationTest.php new file mode 100644 index 0000000..b7881fb --- /dev/null +++ b/tests/Integration/Services/PostTypesServiceIntegrationTest.php @@ -0,0 +1,18 @@ +assertGreaterThanOrEqual(1, $this->wordpress()->postTypes()->list()->count()); + $this->assertSame('post', $this->wordpress()->postTypes()->get('post')->slug); + $this->assertNotEmpty($this->wordpress()->postTypes()->all()); + $this->assertSame('post', $this->wordpress()->postTypes()->cursor()->current()->slug); + } +} diff --git a/tests/Integration/Services/PostsServiceIntegrationTest.php b/tests/Integration/Services/PostsServiceIntegrationTest.php new file mode 100644 index 0000000..620fffb --- /dev/null +++ b/tests/Integration/Services/PostsServiceIntegrationTest.php @@ -0,0 +1,38 @@ +createTestPost(['title' => 'SDK Integration Post']); + $updated = $this->wordpress()->posts()->update($post->id, ['title' => 'SDK Integration Post Updated']); + $listed = $this->wordpress()->posts()->list(['include' => [$post->id], 'status' => 'draft']); + $all = $this->wordpress()->posts()->all(['include' => [$post->id], 'status' => 'draft']); + $cursor = $this->wordpress()->posts()->cursor(['include' => [$post->id], 'status' => 'draft']); + $visited = 0; + + $this->wordpress()->posts()->each(function (Post $item) use (&$visited): bool { + $visited++; + + return false; + }, ['include' => [$post->id], 'status' => 'draft']); + + $this->assertSame($post->id, $this->wordpress()->posts()->get($post->id)->id); + $this->assertStringContainsString('Updated', $updated->title->rendered); + $this->assertGreaterThanOrEqual(1, $listed->count()); + $this->assertCount(1, $all); + $this->assertInstanceOf(Post::class, $cursor->current()); + $this->assertSame(1, $visited); + + $deleted = $this->wordpress()->posts()->delete($post->id, true); + $this->assertSame($post->id, $deleted->id); + $this->createdResources['posts'] = []; + } +} diff --git a/tests/Integration/Services/RawReadServicesIntegrationTest.php b/tests/Integration/Services/RawReadServicesIntegrationTest.php new file mode 100644 index 0000000..fcd80a2 --- /dev/null +++ b/tests/Integration/Services/RawReadServicesIntegrationTest.php @@ -0,0 +1,63 @@ +skipWhenRouteMissing('PluginsService::list', '/wp/v2/plugins'); + $this->skipWhenRouteMissing('ThemesService::list', '/wp/v2/themes'); + + $plugins = $this->wordpress()->plugins()->list(); + $themes = $this->wordpress()->themes()->list(); + $blockTypes = $this->wordpress()->blockTypes()->list('core'); + + try { + $rendered = $this->wordpress()->blockRenderer()->render('core/latest-posts', ['postsToShow' => 1]); + } catch (ValidationException) { + $marker = $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/marker'); + $this->markTestSkipped(sprintf( + 'Skipping BlockRendererService::render in raw read coverage because /wp/v2/block-renderer/core/latest-posts rejects the request on WordPress %s with active theme %s.', + (string) ($marker['wp_version'] ?? 'unknown'), + (string) ($marker['active_theme'] ?? 'unknown') + )); + } + $widgets = $this->wordpress()->widgetTypes()->list(); + $sidebars = $this->wordpress()->sidebars()->list(); + + $this->assertIsArray($plugins); + $this->assertIsArray($themes); + $this->assertIsArray($blockTypes); + $this->assertIsArray($rendered); + $this->assertIsArray($widgets); + $this->assertIsArray($sidebars); + } + + public function testNavigationTemplateAndSiteHealthRoutesAreVersionGated(): void + { + foreach ([ + 'MenuLocationsService::list' => '/wp/v2/menu-locations', + 'NavMenusService::list' => '/wp/v2/menus', + 'NavMenuItemsService::list' => '/wp/v2/menu-items', + 'TemplatesService::list' => '/wp/v2/templates', + 'TemplatePartsService::list' => '/wp/v2/template-parts', + 'GlobalStylesService::list' => '/wp/v2/global-styles', + ] as $method => $route) { + $this->skipWhenRouteMissing($method, $route); + } + + $this->assertIsArray($this->wordpress()->menuLocations()->list()); + $this->assertIsArray($this->wordpress()->navMenus()->list()); + $this->assertIsArray($this->wordpress()->navMenuItems()->list()); + $this->assertIsArray($this->wordpress()->templates()->list()); + $this->assertIsArray($this->wordpress()->templateParts()->list()); + $this->assertIsArray($this->wordpress()->globalStyles()->list()); + $this->assertIsArray($this->wordpress()->siteHealth()->backgroundUpdates()); + } +} diff --git a/tests/Integration/Services/RevisionsServiceIntegrationTest.php b/tests/Integration/Services/RevisionsServiceIntegrationTest.php new file mode 100644 index 0000000..88a6429 --- /dev/null +++ b/tests/Integration/Services/RevisionsServiceIntegrationTest.php @@ -0,0 +1,24 @@ +createTestPost(['status' => 'draft']); + $this->wordpress()->posts()->update($post->id, ['content' => 'Revision content ' . $this->uniquePrefix()]); + $revisions = $this->wordpress()->revisions()->posts($post->id)->list(); + + if ($revisions === []) { + $this->markTestSkipped('Skipping RevisionsService::get because WordPress did not generate a revision for the test post.'); + } + + $revisionId = (int) $revisions[0]['id']; + $this->assertSame($revisionId, (int) $this->wordpress()->revisions()->posts($post->id)->get($revisionId)['id']); + } +} diff --git a/tests/Integration/Services/SearchServiceIntegrationTest.php b/tests/Integration/Services/SearchServiceIntegrationTest.php new file mode 100644 index 0000000..de397b2 --- /dev/null +++ b/tests/Integration/Services/SearchServiceIntegrationTest.php @@ -0,0 +1,31 @@ +uniquePrefix(); + $this->createTestPost(['title' => $keyword, 'content' => $keyword, 'status' => 'publish']); + $results = $this->wordpress()->search()->search(['search' => $keyword, 'subtype' => 'post']); + $all = $this->wordpress()->search()->all(['search' => $keyword, 'subtype' => 'post']); + $visited = 0; + + $this->wordpress()->search()->each(function (SearchResult $result) use (&$visited): bool { + $visited++; + + return false; + }, ['search' => $keyword, 'subtype' => 'post']); + + $this->assertGreaterThanOrEqual(1, $results->count()); + $this->assertNotEmpty($all); + $this->assertInstanceOf(SearchResult::class, $this->wordpress()->search()->cursor(['search' => $keyword])->current()); + $this->assertSame(1, $visited); + } +} diff --git a/tests/Integration/Services/SettingsServiceIntegrationTest.php b/tests/Integration/Services/SettingsServiceIntegrationTest.php new file mode 100644 index 0000000..49f37f9 --- /dev/null +++ b/tests/Integration/Services/SettingsServiceIntegrationTest.php @@ -0,0 +1,24 @@ +wordpress()->settings()->get(); + $original = (string) $settings->get('description', ''); + $updatedValue = 'SDK integration ' . $this->uniquePrefix(); + + try { + $updated = $this->wordpress()->settings()->update(['description' => $updatedValue]); + $this->assertSame($updatedValue, $updated->get('description')); + } finally { + $this->wordpress()->settings()->update(['description' => $original]); + } + } +} diff --git a/tests/Integration/Services/SidebarsServiceIntegrationTest.php b/tests/Integration/Services/SidebarsServiceIntegrationTest.php new file mode 100644 index 0000000..01ffa4b --- /dev/null +++ b/tests/Integration/Services/SidebarsServiceIntegrationTest.php @@ -0,0 +1,15 @@ +assertIsArray($this->wordpress()->sidebars()->list()); + } +} diff --git a/tests/Integration/Services/SiteHealthServiceIntegrationTest.php b/tests/Integration/Services/SiteHealthServiceIntegrationTest.php new file mode 100644 index 0000000..4e8f93d --- /dev/null +++ b/tests/Integration/Services/SiteHealthServiceIntegrationTest.php @@ -0,0 +1,17 @@ +assertIsArray($this->wordpress()->siteHealth()->backgroundUpdates()); + $this->assertIsArray($this->wordpress()->siteHealth()->loopbackRequests()); + $this->assertIsArray($this->wordpress()->siteHealth()->httpsStatus()); + } +} diff --git a/tests/Integration/Services/StatusesServiceIntegrationTest.php b/tests/Integration/Services/StatusesServiceIntegrationTest.php new file mode 100644 index 0000000..7969730 --- /dev/null +++ b/tests/Integration/Services/StatusesServiceIntegrationTest.php @@ -0,0 +1,18 @@ +assertGreaterThanOrEqual(1, $this->wordpress()->statuses()->list()->count()); + $this->assertSame('Published', $this->wordpress()->statuses()->get('publish')->name); + $this->assertNotEmpty($this->wordpress()->statuses()->all()); + $this->assertNotSame('', $this->wordpress()->statuses()->cursor()->current()->name); + } +} diff --git a/tests/Integration/Services/TagsServiceIntegrationTest.php b/tests/Integration/Services/TagsServiceIntegrationTest.php new file mode 100644 index 0000000..791ef80 --- /dev/null +++ b/tests/Integration/Services/TagsServiceIntegrationTest.php @@ -0,0 +1,22 @@ +createTestTag(); + $updated = $this->wordpress()->tags()->update($tag->id, ['description' => 'Updated tag']); + + $this->assertSame($tag->id, $this->wordpress()->tags()->get($tag->id)->id); + $this->assertGreaterThanOrEqual(1, $this->wordpress()->tags()->list(['slug' => $tag->slug])->count()); + $this->assertSame('Updated tag', $updated->description); + $this->assertSame($tag->id, $this->wordpress()->tags()->delete($tag->id, true)->id); + $this->createdResources['tags'] = []; + } +} diff --git a/tests/Integration/Services/TaxonomiesServiceIntegrationTest.php b/tests/Integration/Services/TaxonomiesServiceIntegrationTest.php new file mode 100644 index 0000000..12ac6bf --- /dev/null +++ b/tests/Integration/Services/TaxonomiesServiceIntegrationTest.php @@ -0,0 +1,20 @@ +wordpress()->taxonomies()->list(); + + $this->assertGreaterThanOrEqual(1, $taxonomies->count()); + $this->assertSame('category', $this->wordpress()->taxonomies()->get('category')->slug); + $this->assertNotEmpty($this->wordpress()->taxonomies()->all()); + $this->assertSame('category', $this->wordpress()->taxonomies()->cursor()->current()->slug); + } +} diff --git a/tests/Integration/Services/TemplatePartsServiceIntegrationTest.php b/tests/Integration/Services/TemplatePartsServiceIntegrationTest.php new file mode 100644 index 0000000..0032157 --- /dev/null +++ b/tests/Integration/Services/TemplatePartsServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('TemplatePartsService::list', '/wp/v2/template-parts'); + + $this->assertIsArray($this->wordpress()->templateParts()->list()); + } +} diff --git a/tests/Integration/Services/TemplatesServiceIntegrationTest.php b/tests/Integration/Services/TemplatesServiceIntegrationTest.php new file mode 100644 index 0000000..af929d0 --- /dev/null +++ b/tests/Integration/Services/TemplatesServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('TemplatesService::list', '/wp/v2/templates'); + + $this->assertIsArray($this->wordpress()->templates()->list()); + } +} diff --git a/tests/Integration/Services/ThemesServiceIntegrationTest.php b/tests/Integration/Services/ThemesServiceIntegrationTest.php new file mode 100644 index 0000000..af3e243 --- /dev/null +++ b/tests/Integration/Services/ThemesServiceIntegrationTest.php @@ -0,0 +1,19 @@ +skipWhenRouteMissing('ThemesService::list', '/wp/v2/themes'); + $marker = $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/marker'); + + $this->assertIsArray($this->wordpress()->themes()->list()); + $this->assertIsArray($this->wordpress()->themes()->get((string) $marker['active_theme'])); + } +} diff --git a/tests/Integration/Services/UsersServiceIntegrationTest.php b/tests/Integration/Services/UsersServiceIntegrationTest.php new file mode 100644 index 0000000..5ba7f33 --- /dev/null +++ b/tests/Integration/Services/UsersServiceIntegrationTest.php @@ -0,0 +1,22 @@ +wordpress()->users()->me(); + $user = $this->createTestUser(); + $updated = $this->wordpress()->users()->update($user->id, ['name' => 'SDK Test User Updated']); + + $this->assertSame(self::env('WORDPRESS_USER'), $me->slug); + $this->assertSame($user->id, $this->wordpress()->users()->get($user->id)->id); + $this->assertGreaterThanOrEqual(1, $this->wordpress()->users()->list(['search' => $user->slug])->count()); + $this->assertSame('SDK Test User Updated', $updated->name); + } +} diff --git a/tests/Integration/Services/WidgetTypesServiceIntegrationTest.php b/tests/Integration/Services/WidgetTypesServiceIntegrationTest.php new file mode 100644 index 0000000..f11daad --- /dev/null +++ b/tests/Integration/Services/WidgetTypesServiceIntegrationTest.php @@ -0,0 +1,18 @@ +wordpress()->widgetTypes()->list(); + + $this->assertIsArray($types); + $this->assertIsArray($this->wordpress()->widgetTypes()->get('block')); + } +} diff --git a/tests/Integration/Services/WidgetsServiceIntegrationTest.php b/tests/Integration/Services/WidgetsServiceIntegrationTest.php new file mode 100644 index 0000000..87e409c --- /dev/null +++ b/tests/Integration/Services/WidgetsServiceIntegrationTest.php @@ -0,0 +1,17 @@ +skipWhenRouteMissing('WidgetsService::list', '/wp/v2/widgets'); + + $this->assertIsArray($this->wordpress()->widgets()->list()); + } +} diff --git a/tests/Integration/Support/IntegrationTestCase.php b/tests/Integration/Support/IntegrationTestCase.php new file mode 100644 index 0000000..5830d5a --- /dev/null +++ b/tests/Integration/Support/IntegrationTestCase.php @@ -0,0 +1,252 @@ +> */ + protected array $createdResources = [ + 'posts' => [], + 'pages' => [], + 'categories' => [], + 'tags' => [], + 'comments' => [], + 'media' => [], + 'users' => [], + ]; + + public static function setUpBeforeClass(): void + { + self::loadIntegrationEnvironment(); + self::assertSafeIntegrationEnvironment(); + self::assertMarkerOptionExists(); + } + + protected function tearDown(): void + { + $this->cleanupTestResources(); + parent::tearDown(); + } + + protected static function loadIntegrationEnvironment(): void + { + $root = dirname(__DIR__, 3); + foreach (['.env.integration', '.env'] as $file) { + if (is_file($root . '/' . $file)) { + Dotenv::createImmutable($root, $file)->safeLoad(); + } + } + } + + public static function assertSafeIntegrationEnvironment(): void + { + if (($_ENV['WORDPRESS_ENV'] ?? getenv('WORDPRESS_ENV') ?: null) !== 'testing') { + self::markTestSkipped('Integration tests require WORDPRESS_ENV=testing.'); + } + + $url = self::env('WORDPRESS_URL'); + if ($url === '') { + self::markTestSkipped('Integration tests require WORDPRESS_URL.'); + } + + $host = parse_url($url, PHP_URL_HOST); + $allowedHosts = ['localhost', '127.0.0.1', 'wordpress']; + if (!is_string($host) || !in_array($host, $allowedHosts, true)) { + self::markTestSkipped(sprintf( + 'Refusing integration tests for non-local WORDPRESS_URL host "%s".', + is_scalar($host) ? (string) $host : 'unknown' + )); + } + } + + protected static function assertMarkerOptionExists(): void + { + try { + $marker = self::wordpressStatic()->custom()->get('/jooservices-sdk-test/v1/marker'); + } catch (\Throwable $exception) { + self::markTestSkipped('Integration marker route is unavailable; run composer integration:setup.'); + } + + if (($marker['jooservices_sdk_test_site'] ?? 0) !== 1) { + self::markTestSkipped('Refusing destructive integration tests because jooservices_sdk_test_site marker is missing.'); + } + } + + protected static function env(string $key): string + { + $value = $_ENV[$key] ?? getenv($key) ?: ''; + + return is_string($value) ? $value : ''; + } + + protected static function wordpressStatic(): WordPressService + { + if (self::$wordpressServiceCache instanceof WordPressService) { + return self::$wordpressServiceCache; + } + + $user = self::env('WORDPRESS_USER'); + $password = self::env('WORDPRESS_APP_PASSWORD'); + if ($user === '' || $password === '') { + self::markTestSkipped('Integration tests require WORDPRESS_USER and WORDPRESS_APP_PASSWORD.'); + } + + self::$wordpressServiceCache = WordPressService::create( + baseUrl: self::env('WORDPRESS_URL'), + username: $user, + password: $password, + ); + + return self::$wordpressServiceCache; + } + + protected function wordpress(): WordPressService + { + return self::wordpressStatic(); + } + + protected function uniquePrefix(): string + { + return 'sdk_test_' . bin2hex(random_bytes(6)); + } + + protected function createTestPost(array $payload = []): Post + { + $post = $this->wordpress()->posts()->create(array_merge([ + 'title' => $this->uniquePrefix() . ' post', + 'content' => 'Integration test content.', + 'status' => 'draft', + ], $payload)); + $this->createdResources['posts'][] = $post->id; + + return $post; + } + + protected function createTestPage(array $payload = []): Page + { + $page = $this->wordpress()->pages()->create(array_merge([ + 'title' => $this->uniquePrefix() . ' page', + 'content' => 'Integration test page.', + 'status' => 'draft', + ], $payload)); + $this->createdResources['pages'][] = $page->id; + + return $page; + } + + protected function createTestCategory(array $payload = []): Term + { + $name = $this->uniquePrefix() . ' category'; + $term = $this->wordpress()->categories()->create(array_merge([ + 'name' => $name, + 'slug' => str_replace('_', '-', strtolower($name)), + ], $payload)); + $this->createdResources['categories'][] = $term->id; + + return $term; + } + + protected function createTestTag(array $payload = []): Term + { + $name = $this->uniquePrefix() . ' tag'; + $term = $this->wordpress()->tags()->create(array_merge([ + 'name' => $name, + 'slug' => str_replace('_', '-', strtolower($name)), + ], $payload)); + $this->createdResources['tags'][] = $term->id; + + return $term; + } + + protected function createTestUser(array $payload = []): User + { + $prefix = $this->uniquePrefix(); + $user = $this->wordpress()->users()->create(array_merge([ + 'username' => $prefix, + 'email' => $prefix . '@example.test', + 'password' => 'sdk_test_user_password', + 'roles' => ['subscriber'], + ], $payload)); + $this->createdResources['users'][] = $user->id; + + return $user; + } + + protected function uploadTestMedia(): int + { + $path = dirname(__DIR__, 3) . '/docker/wordpress/fixtures/media/test-image.png'; + $media = $this->wordpress()->media()->upload($path, [ + 'title' => $this->uniquePrefix() . ' media', + ]); + $this->createdResources['media'][] = $media->id; + + return $media->id; + } + + protected function cleanupTestResources(): void + { + $wp = $this->wordpress(); + + foreach (array_reverse($this->createdResources['comments']) as $id) { + $this->ignoreCleanupFailure(fn () => $wp->comments()->delete((int) $id, true)); + } + foreach (array_reverse($this->createdResources['posts']) as $id) { + $this->ignoreCleanupFailure(fn () => $wp->posts()->delete((int) $id, true)); + } + foreach (array_reverse($this->createdResources['pages']) as $id) { + $this->ignoreCleanupFailure(fn () => $wp->pages()->delete((int) $id, true)); + } + foreach (array_reverse($this->createdResources['media']) as $id) { + $this->ignoreCleanupFailure(fn () => $wp->media()->delete((int) $id, true)); + } + foreach (array_reverse($this->createdResources['categories']) as $id) { + $this->ignoreCleanupFailure(fn () => $wp->categories()->delete((int) $id, true)); + } + foreach (array_reverse($this->createdResources['tags']) as $id) { + $this->ignoreCleanupFailure(fn () => $wp->tags()->delete((int) $id, true)); + } + foreach (array_reverse($this->createdResources['users']) as $id) { + $this->ignoreCleanupFailure(fn () => $wp->users()->delete((int) $id, true)); + } + } + + protected function routeExists(string $route): bool + { + return array_key_exists($route, $this->wordpress()->discovery()->routes()); + } + + protected function skipWhenRouteMissing(string $method, string $route): void + { + if (!$this->routeExists($route)) { + $marker = $this->wordpress()->custom()->get('/jooservices-sdk-test/v1/marker'); + self::markTestSkipped(sprintf( + 'Skipping %s because %s route is unavailable on WordPress %s with active theme %s.', + $method, + $route, + (string) ($marker['wp_version'] ?? 'unknown'), + (string) ($marker['active_theme'] ?? 'unknown') + )); + } + } + + private function ignoreCleanupFailure(callable $cleanup): void + { + try { + $cleanup(); + } catch (WordPressApiException) { + } catch (\RuntimeException) { + } + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 98243bd..9e36276 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,97 +4,43 @@ namespace JOOservices\WordPress\Sdk\Tests; -use Dotenv\Dotenv; use Faker\Factory; use Faker\Generator; +use JOOservices\WordPress\Sdk\Tests\Integration\Support\IntegrationTestCase; use JOOservices\WordPress\Sdk\WordPressService; -use PHPUnit\Framework\TestCase as BaseTestCase; -abstract class TestCase extends BaseTestCase +abstract class TestCase extends IntegrationTestCase { - protected static ?WordPressService $wp = null; private ?Generator $faker = null; - // Resource tracking + /** @var int[] */ protected array $createdPostIds = []; + /** @var int[] */ protected array $createdCategoryIds = []; + /** @var int[] */ protected array $createdTagIds = []; + /** @var int[] */ protected array $createdMediaIds = []; - /** - * Check if the configured WordPress server is reachable. - * Returns true if reachable, false otherwise. - */ - protected static function isWordPressReachable(): bool - { - $url = $_ENV['WORDPRESS_URL'] ?? null; - if (!$url) { - return false; - } - - // Try to fetch /wp-json/ endpoint - $checkUrl = rtrim($url, '/') . '/wp-json/'; - $ch = curl_init($checkUrl); - curl_setopt($ch, CURLOPT_NOBODY, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 3); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - $result = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - // curl_close() is deprecated in PHP 8.5+, so only call if not deprecated - if (PHP_VERSION_ID < 80500) { - curl_close($ch); - } - - return $result !== false && $httpCode >= 200 && $httpCode < 500; - } - - public static function setUpBeforeClass(): void - { - // Load .env from project root if it exists - $rootDir = dirname(__DIR__); - if (file_exists($rootDir . '/.env')) { - $dotenv = Dotenv::createImmutable($rootDir); - $dotenv->load(); - } - - // Check if WordPress is reachable for integration tests - if (!self::isWordPressReachable()) { - self::markTestSkipped('Integration tests skipped: WordPress server is not reachable.'); - } - } - protected function tearDown(): void { + $this->createdResources['posts'] = array_merge($this->createdResources['posts'], $this->createdPostIds); + $this->createdResources['categories'] = array_merge($this->createdResources['categories'], $this->createdCategoryIds); + $this->createdResources['tags'] = array_merge($this->createdResources['tags'], $this->createdTagIds); + $this->createdResources['media'] = array_merge($this->createdResources['media'], $this->createdMediaIds); + parent::tearDown(); - $this->cleanupResources(); } protected function createService(): WordPressService { - if (self::$wp !== null) { - return self::$wp; - } - - $url = $_ENV['WORDPRESS_URL'] ?? null; - $user = $_ENV['WORDPRESS_USER'] ?? null; - $pass = $_ENV['WORDPRESS_APP_PASSWORD'] ?? null; - - if (!$url || !$user || !$pass) { - $this->markTestSkipped('Integration tests require missing WORDPRESS_URL, WORDPRESS_USER, or WORDPRESS_APP_PASSWORD environment variables.'); - } - - self::$wp = WordPressService::create( - baseUrl: $url, - username: $user, - password: $pass - ); - - return self::$wp; + return $this->wordpress(); } protected function createCustomService(string $url, string $user, string $pass): WordPressService { + self::assertSafeIntegrationEnvironment(); + return WordPressService::create( baseUrl: $url, username: $user, @@ -110,60 +56,4 @@ protected function faker(): Generator return $this->faker; } - - protected function cleanupResources(): void - { - if (!empty($this->createdPostIds)) { - $this->deletePosts($this->createdPostIds); - } - if (!empty($this->createdCategoryIds)) { - $this->deleteCategories($this->createdCategoryIds); - } - if (!empty($this->createdTagIds)) { - $this->deleteTags($this->createdTagIds); - } - if (!empty($this->createdMediaIds)) { - $this->deleteMedia($this->createdMediaIds); - } - } - - protected function deletePosts(int|array $ids): void - { - $this->deleteResources($ids, 'posts', 'createdPostIds'); - } - - protected function deleteCategories(int|array $ids): void - { - $this->deleteResources($ids, 'categories', 'createdCategoryIds'); - } - - protected function deleteTags(int|array $ids): void - { - $this->deleteResources($ids, 'tags', 'createdTagIds'); - } - - protected function deleteMedia(int|array $ids): void - { - $this->deleteResources($ids, 'media', 'createdMediaIds'); - } - - private function deleteResources(int|array $ids, string $resourceType, string $trackingProperty): void - { - $ids = (array) $ids; - if (empty($ids)) { - return; - } - - $service = $this->createService(); - foreach ($ids as $id) { - try { - $service->$resourceType()->delete($id, force: true); - } catch (\Throwable $e) { - // Ignore cleanup errors - } - } - - // Remove from tracking array to avoid double deletion - $this->$trackingProperty = array_diff($this->$trackingProperty, $ids); - } }