diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..80de2de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,147 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + static: + name: Lint & coding standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + coverage: none + tools: composer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Syntax check every file + run: composer run lint + + - name: WordPress coding standards + run: composer run phpcs + + test: + name: PHP ${{ matrix.php }} / WP ${{ matrix.wp }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # The plugin header claims PHP 7.4+; both ends of that range are + # tested against current WordPress, plus two older WordPress versions. + # + # WordPress 5.9 rather than the declared 5.8 floor: WordPress's own + # test library only gained PHPUnit 9 support in 5.9, and 5.8 hard-fails + # with "compatible with PHPUnit up to 7.x". 5.8 support is therefore + # verified by the PHPCompatibility/manual checks, not by this suite. + php: ['7.4', '8.2', '8.4'] + wp: ['latest'] + include: + - php: '7.4' + wp: '5.9.*' + - php: '8.3' + wp: '6.5.*' + + services: + mysql: + image: mariadb:10.11 + env: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1 + MARIADB_DATABASE: wordpress_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=6 + + env: + WP_TESTS_DB_HOST: 127.0.0.1:3306 + WP_TESTS_DB_NAME: wordpress_test + WP_TESTS_DB_USER: root + WP_TESTS_DB_PASSWORD: '' + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mysqli + coverage: none + tools: composer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + # WordPress core and the test library are separate packages that share + # WordPress's version numbers — they must be pinned together or the + # library boots against a core it does not match. + - name: Pin WordPress to ${{ matrix.wp }} + if: matrix.wp != 'latest' + run: >- + composer require --dev --update-with-dependencies --no-progress + "roots/wordpress-no-content:${{ matrix.wp }}" + "wp-phpunit/wp-phpunit:${{ matrix.wp }}" + + - name: Run the test suite + run: composer run test:core + + - name: Run the test suite with the WooCommerce store rules active + run: composer run test:woocommerce + + test-woocommerce: + name: WooCommerce integration + runs-on: ubuntu-latest + + services: + mysql: + image: mariadb:10.11 + env: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1 + MARIADB_DATABASE: wordpress_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=6 + + env: + WP_TESTS_DB_HOST: 127.0.0.1:3306 + WP_TESTS_DB_NAME: wordpress_test + WP_TESTS_DB_USER: root + WP_TESTS_DB_PASSWORD: '' + CITECUE_WITH_WOOCOMMERCE: '1' + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mysqli + coverage: none + tools: composer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Install WooCommerce + run: composer require --dev --no-progress wpackagist-plugin/woocommerce + + - name: Run the product-push tests against a real WooCommerce + run: composer run test:core diff --git a/.gitignore b/.gitignore index 17b1de0..4aaf3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .DS_Store node_modules/ vendor/ +composer.lock +.phpunit.result.cache +phpunit.xml +.phpcs.xml diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist new file mode 100644 index 0000000..bd754e9 --- /dev/null +++ b/.phpcs.xml.dist @@ -0,0 +1,95 @@ + + + WordPress coding standards for the CiteCue plugin. + + . + /vendor/* + /node_modules/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /includes/class-citecue-proxy\.php + /includes/class-citecue-llms-txt\.php + + + + + /includes/class-citecue-llms-txt\.php + + + + + /includes/index\.php + + + + + /tests/* + + + /tests/* + + + /tests/* + + + /tests/* + + + /tests/* + + + /tests/* + + + + /tests/* + + + + /tests/bootstrap\.php + + diff --git a/README.md b/README.md index 8e253bc..1e03fa0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor - **One request serves and reports.** The v2 delivery endpoint records the crawler hit server-side (`served` for 200/304, `passthrough` for a miss), so CiteCue's Agent Traffic dashboard stays accurate with no extra beacon. - **Conditional revalidation.** Optimized bodies are cached locally with their ETag; revalidation is a cheap 304 round-trip. Misses are negative-cached for 60 s (mirroring the API's `max-age=60` miss sentinel). - **Circuit breaker.** A timeout or 5xx opens a 60 s circuit (10 min on a rejected key): no API calls, stale cache served when available, plain pass-through otherwise. A CiteCue outage never slows human traffic — the API is only ever called for AI-crawler requests in the first place. -- **Abuse-bounded.** Cache keys use CiteCue-compatible URL normalization (tracking params, `www.`, trailing slashes deduped), and outbound lookups are capped by a per-minute budget (default 120, filterable) — a spoofed crawler UA spraying unique URLs cannot force unbounded API calls. When CiteCue reports a page is no longer optimized, its cached copy is evicted immediately. +- **Abuse-bounded.** Cache keys use CiteCue-compatible URL normalization (tracking params, `www.`, trailing slashes deduped), and outbound lookups are capped by a per-minute budget shared across the crawler and llms.txt paths (default 120, filterable) — neither a spoofed crawler UA spraying unique URLs nor a flood on `/llms.txt` can force unbounded API calls. When CiteCue reports a page is no longer optimized, its cached copy is evicted immediately. - **Crawler registry.** A bundled token list ships with the plugin and refreshes daily from the public `GET /api/delivery/v1/crawlers` feed, so newly added crawlers are served without a plugin update. - **Verification-compatible.** Served pages carry `X-Citecue: served` and llms.txt carries `X-Citecue: llms-txt` — the headers CiteCue's *Verify installation* button probes for. @@ -123,11 +123,26 @@ With WooCommerce active: | `citecue_matched_crawler` | filter | Override per-request crawler matching | | `citecue_should_serve` | filter | Veto serving for a specific request | | `citecue_serve_timeout` | filter | Delivery API timeout on the serving path (default 3 s) | -| `citecue_lookup_budget` | filter | Max delivery API lookups per minute (default 120); beyond it, crawler requests pass through | +| `citecue_lookup_budget` | filter | Max delivery API lookups per minute across the crawler and llms.txt paths combined (default 120); beyond it, requests are answered from cache or passed through | | `citecue_ingest_postarr` | filter | Adjust the post array before insert/update | | `citecue_ingest_rate_limit` | filter | Ingest requests allowed per hour (default 120) | | `citecue_output_meta_description` | filter | Control the meta-description tag for pushed content | +## Performance + +The delivery API is only ever called for an AI-crawler request or for `/llms.txt`. A human page view does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read). + +For a crawler request the cost is one API call, with a 3 s timeout, and only when the local cache cannot answer: optimized bodies are cached for 24 h and revalidated with an ETag, misses are negative-cached for 60 s, and llms.txt is treated as fresh for 5 minutes. A repeat crawl of a cached page is a 304, not a re-download. + +- **A persistent object cache is recommended.** Cached bodies are transients. With Redis or Memcached they never touch the database. Without one they are rows in `wp_options` — full HTML documents, one per crawled URL, for up to 24 h. They are not autoloaded, so they cost nothing per request, but a heavily crawled site can hold tens of megabytes there until WordPress's twice-daily transient cleanup runs. +- **The outbound-call ceiling is per site, not per path.** The 120/minute budget covers crawler lookups and llms.txt together, so no mix of traffic can exceed it. + +## What happens if CiteCue is unavailable + +Nothing that a visitor can see. The first failing request waits at most 3 s and then opens the circuit for 60 s (10 minutes if the API key was rejected). While it is open the plugin makes no API calls at all: it serves the cached body if it has one — marked `X-Citecue-Cache: stale` — and otherwise falls straight through to normal theme output. Because bodies are kept for 24 h, crawlers keep receiving optimized pages through a short outage. + +The worst case for an AI crawler is one 3 s wait per minute. For a human visitor there is no worst case: the API is never consulted on their behalf. + ## Notes & caveats - **Full-page caches / CDNs:** a page cache that serves HTML before WordPress loads will answer AI crawlers with the cached human version. Exclude the AI-crawler user agents from your page cache, or rely on CiteCue's Cloudflare Worker install instead of this plugin when your cache sits in front of PHP. Responses served by this plugin set `DONOTCACHEPAGE` and `Cache-Control: private, no-store` so they are never stored for humans. @@ -137,4 +152,46 @@ With WooCommerce active: ## Development -Plain PHP ≥ 7.4, no build step. Repo root is the plugin root. `php -l` every file; WordPress coding standards style. +Plain PHP ≥ 7.4, no build step. Repo root is the plugin root, so the checkout can be symlinked straight into `wp-content/plugins/`. + +### Tests + +The suite is WordPress integration tests: real options, transients, REST requests and query conditionals, with the CiteCue API faked at the `wp_remote_get` layer (`tests/includes/class-citecue-http-mock.php`) so no test ever touches the network. WordPress core and its test library both come from Composer — there is nothing to download by hand. + +```bash +composer install +mysqladmin create wordpress_test -uroot # any empty database will do +composer test +``` + +Point it at a different database with `WP_TESTS_DB_NAME`, `WP_TESTS_DB_USER`, `WP_TESTS_DB_PASSWORD`, `WP_TESTS_DB_HOST` (see `tests/wp-tests-config.php`). + +`composer test` runs the suite twice, because whether WooCommerce exists is a process-wide fact rather than something a single test can toggle: + +| Command | Covers | +|---|---| +| `composer test:core` | Everything, with no WooCommerce present | +| `composer test:woocommerce` | Adds a minimal WooCommerce stand-in so the store-page exclusion rules are exercised | +| `CITECUE_WITH_WOOCOMMERCE=1 composer test:core` | Product pushes against a real WooCommerce — needs `composer require --dev wpackagist-plugin/woocommerce` | + +Other commands: + +```bash +composer lint # php -l over every file +composer phpcs # WordPress coding standards + PHP 7.4 compatibility +composer phpcbf # auto-fix what phpcs can +``` + +CI runs the static checks plus the suite on PHP 7.4/8.2/8.4 against current WordPress, on PHP 7.4/8.3 against WordPress 5.9/6.5, and a separate job against a real WooCommerce. WordPress 5.9 is the oldest version the automated suite can cover — WordPress's own test library only supports PHPUnit 9 from 5.9 onwards — so the declared 5.8 floor rests on the PHPCompatibility checks and manual verification. + +### Testing an install by hand + +```bash +curl -si -A GPTBot https://your-site.com/llms.txt # expect: x-citecue: llms-txt +curl -si -A GPTBot https://your-site.com/optimized-page/ # expect: x-citecue: served +curl -s https://your-site.com/wp-json/citecue/v1/health # plugin/version/delivery/ingest/woocommerce +``` + +### Structure notes + +`Citecue_Proxy` and `Citecue_Llms_Txt` each split into a `decide()` that returns what should happen and a `serve()` that emits headers and calls `exit`. All the branching lives in `decide()`, which is what the tests drive; `serve()` stays deliberately trivial because nothing can assert against a request that has already ended. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..80a7f5e --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "citecue/wordpress-plugin", + "description": "CiteCue AI Auto-Fix — WordPress plugin", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "yoast/phpunit-polyfills": "^2.0", + "squizlabs/php_codesniffer": "^3.9", + "wp-coding-standards/wpcs": "^3.1", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "wp-phpunit/wp-phpunit": "^7.0", + "roots/wordpress-no-content": "^6.8", + "composer/installers": "^2.2" + }, + "repositories": [ + { + "type": "composer", + "url": "https://wpackagist.org", + "only": [ + "wpackagist-plugin/*" + ] + } + ], + "extra": { + "installer-paths": { + "vendor/wpackagist-plugin/{$name}/": [ + "type:wordpress-plugin" + ] + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "composer/installers": true + } + }, + "scripts": { + "lint": "find citecue.php uninstall.php includes tests -name '*.php' -print0 | xargs -0 -n1 -P4 php -l > /dev/null", + "phpcs": "phpcs", + "phpcbf": "phpcbf", + "test": [ + "@test:core", + "@test:woocommerce" + ], + "test:core": "phpunit", + "test:woocommerce": "CITECUE_STUB_WOOCOMMERCE=1 phpunit" + } +} diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php index 890b278..d57511d 100644 --- a/includes/class-citecue-cache.php +++ b/includes/class-citecue-cache.php @@ -175,6 +175,31 @@ public function set_miss( $url ) { set_transient( 'citecue_ms_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ), 1, self::MISS_TTL ); } + /** + * Consumes one unit of the per-minute outbound-lookup budget, shared by + * every path that calls the delivery API. Bounds the requests an anonymous + * visitor can trigger — by spoofing a crawler User-Agent across unique + * URLs, or by hammering llms.txt — to a known ceiling per site. Beyond it + * the plugin degrades exactly as it does with an open circuit. + * + * @return bool Whether an API lookup may be made. + */ + public function consume_lookup_budget() { + /** + * Filters the maximum delivery API lookups per minute. + * + * @param int $limit Default 120. + */ + $limit = max( 1, (int) apply_filters( 'citecue_lookup_budget', 120 ) ); + $key = 'citecue_budget_' . (int) floor( time() / MINUTE_IN_SECONDS ); + $count = (int) get_transient( $key ); + if ( $count >= $limit ) { + return false; + } + set_transient( $key, $count + 1, 2 * MINUTE_IN_SECONDS ); + return true; + } + /** * Cached llms.txt, or null. * @@ -194,6 +219,27 @@ public function delete_llms_txt() { delete_transient( 'citecue_llms_' . $this->salt() ); } + /** + * Whether CiteCue recently reported that this project publishes no + * llms.txt. Unlike the page path, /llms.txt is served to every visitor, so + * without this a project with llms.txt switched off would turn each hit on + * the URL into an outbound API call. + * + * @return bool + */ + public function is_recent_llms_txt_miss() { + return (bool) get_transient( 'citecue_llms_ms_' . $this->salt() ); + } + + /** + * Records that CiteCue has no llms.txt for this project. + * + * @return void + */ + public function set_llms_txt_miss() { + set_transient( 'citecue_llms_ms_' . $this->salt(), 1, self::MISS_TTL ); + } + /** * Stores the llms.txt body. * diff --git a/includes/class-citecue-llms-txt.php b/includes/class-citecue-llms-txt.php index 7fad657..4b68d09 100644 --- a/includes/class-citecue-llms-txt.php +++ b/includes/class-citecue-llms-txt.php @@ -53,13 +53,27 @@ public function register() { * @return void */ public function maybe_serve() { + $decision = $this->decide(); + + if ( $decision['serve'] ) { + $this->serve( $decision['body'] ); + } + } + + /** + * Decides what this request should get, without emitting anything — the + * testable counterpart of {@see self::serve()}, which ends the request. + * + * @return array{serve:bool,body:string,reason:string} + */ + public function decide() { if ( ! $this->is_llms_txt_request() ) { - return; + return self::pass( 'not-llms-txt' ); } $settings = $this->plugin->settings; if ( ! $settings->get( 'llms_txt_enabled' ) || ! $settings->is_delivery_configured() ) { - return; + return self::pass( 'not-configured' ); } $cache = $this->plugin->cache; @@ -67,51 +81,92 @@ public function maybe_serve() { // Fresh cached copy or open circuit: serve locally, no API call. if ( $cached && ( $cache->is_fresh( $cached, self::FRESH_SECONDS ) || $cache->is_circuit_open() ) ) { - $this->serve( $cached['body'] ); + return self::body( $cached['body'], 'cached' ); } if ( $cache->is_circuit_open() ) { - return; + return self::pass( 'circuit-open' ); + } + + // CiteCue recently said this project has no llms.txt: skip the API for + // a minute. This endpoint answers every visitor, not just crawlers, so + // without the negative cache a project with llms.txt switched off would + // make one outbound call per hit on the URL. + if ( $cache->is_recent_llms_txt_miss() ) { + return self::pass( 'recent-miss' ); + } + + // Shared with the crawler path, so the site's total outbound calls stay + // bounded however the traffic is distributed between the two. + if ( ! $cache->consume_lookup_budget() ) { + return $cached ? self::body( $cached['body'], 'budget-exhausted' ) : self::pass( 'budget-exhausted' ); } $response = $this->plugin->api->get_llms_txt( $cached ? $cached['etag'] : '' ); if ( is_wp_error( $response ) ) { $cache->trip_circuit(); - if ( $cached ) { - $this->serve( $cached['body'] ); - } - return; + return $cached ? self::body( $cached['body'], 'transport-error' ) : self::pass( 'transport-error' ); } switch ( $response['status'] ) { case 200: $cache->set_llms_txt( $response['body'], $response['etag'] ); - $this->serve( $response['body'] ); - return; + return self::body( $response['body'], 'fresh' ); case 304: - if ( $cached ) { - $cache->set_llms_txt( $cached['body'], $cached['etag'] ); - $this->serve( $cached['body'] ); + if ( ! $cached ) { + return self::pass( 'revalidated-without-cache' ); } - return; + $cache->set_llms_txt( $cached['body'], $cached['etag'] ); + return self::body( $cached['body'], 'revalidated' ); case 401: update_option( 'citecue_auth_failed', time(), false ); $cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL ); - return; + return self::pass( 'unauthorized' ); case 404: // llms.txt serving disabled on CiteCue: evict the cached copy - // (so it cannot resurface stale) and fall through to WordPress. + // (so it cannot resurface stale), remember the miss so the next + // visitors are answered locally, and fall through to WordPress. $cache->delete_llms_txt(); - return; + $cache->set_llms_txt_miss(); + return self::pass( 'disabled-upstream' ); default: - return; + return self::pass( 'server-error' ); } } + /** + * A "leave this request to WordPress" decision. + * + * @param string $reason Why nothing is served (diagnostic only). + * @return array{serve:bool,body:string,reason:string} + */ + private static function pass( $reason ) { + return array( + 'serve' => false, + 'body' => '', + 'reason' => $reason, + ); + } + + /** + * A "serve this llms.txt body" decision. + * + * @param string $body llms.txt content. + * @param string $reason Where the body came from (diagnostic only). + * @return array{serve:bool,body:string,reason:string} + */ + private static function body( $body, $reason ) { + return array( + 'serve' => true, + 'body' => $body, + 'reason' => $reason, + ); + } + /** * Whether the current request targets llms.txt at the site root * (or the WordPress subdirectory root for subdirectory installs). diff --git a/includes/class-citecue-proxy.php b/includes/class-citecue-proxy.php index deaae75..cec994b 100644 --- a/includes/class-citecue-proxy.php +++ b/includes/class-citecue-proxy.php @@ -52,24 +52,39 @@ public function register() { * @return void */ public function maybe_serve() { + $decision = $this->decide(); + + if ( $decision['serve'] ) { + $this->serve( $decision['body'], $decision['mode'], $decision['stale'] ); + } + } + + /** + * Decides what this request should get, without emitting anything. All of + * the serving logic lives here so it can be exercised (and tested) without + * the headers-and-exit of {@see self::serve()}. + * + * @return array{serve:bool,body:string,mode:string,stale:bool,reason:string} + */ + public function decide() { if ( ! $this->is_eligible_request() ) { - return; + return self::pass( 'not-eligible' ); } $settings = $this->plugin->settings; if ( ! $settings->get( 'serve_enabled' ) || ! $settings->is_delivery_configured() ) { - return; + return self::pass( 'not-configured' ); } $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $crawler = $this->plugin->crawlers->match( $user_agent ); if ( null === $crawler ) { - return; + return self::pass( 'not-a-crawler' ); } $url = $this->current_url(); if ( '' === $url ) { - return; + return self::pass( 'no-url' ); } /** @@ -80,7 +95,7 @@ public function maybe_serve() { * @param string $url Absolute request URL. */ if ( ! apply_filters( 'citecue_should_serve', true, $crawler, $url ) ) { - return; + return self::pass( 'vetoed' ); } $path = (string) wp_parse_url( $url, PHP_URL_PATH ); @@ -89,7 +104,7 @@ public function maybe_serve() { // Recent miss for this URL: skip the API for a minute (mirrors the // API's own max-age=60 on the miss sentinel). if ( $cache->is_recent_miss( $url ) ) { - return; + return self::pass( 'recent-miss' ); } $cached = $cache->get_page( $url ); @@ -97,20 +112,18 @@ public function maybe_serve() { // Circuit open (recent timeout/auth failure): no API calls. Serve the // stale cached copy when we have one, otherwise pass through. if ( $cache->is_circuit_open() ) { - if ( $cached ) { - $this->serve( $cached['body'], $cached['mode'], true ); - } - return; + return $cached + ? self::serve_stale( $cached, 'circuit-open' ) + : self::pass( 'circuit-open' ); } // Global lookup budget: a spoofed crawler UA spraying unique URLs // cannot force unbounded outbound API calls. Exhausted budget degrades // exactly like an open circuit. - if ( ! $this->consume_lookup_budget() ) { - if ( $cached ) { - $this->serve( $cached['body'], $cached['mode'], true ); - } - return; + if ( ! $cache->consume_lookup_budget() ) { + return $cached + ? self::serve_stale( $cached, 'budget-exhausted' ) + : self::pass( 'budget-exhausted' ); } $response = $this->plugin->api->get_page( $url, $crawler, $cached ? $cached['etag'] : '' ); @@ -119,34 +132,44 @@ public function maybe_serve() { // Timeout / connection failure: open the circuit and degrade. $cache->trip_circuit(); $this->plugin->activity->record( $crawler, $path, $cached ? 'served-stale' : 'error' ); - if ( $cached ) { - $this->serve( $cached['body'], $cached['mode'], true ); - } - return; + return $cached + ? self::serve_stale( $cached, 'transport-error' ) + : self::pass( 'transport-error' ); } switch ( $response['status'] ) { case 200: $cache->set_page( $url, $response['body'], $response['etag'], $response['mode'] ); $this->plugin->activity->record( $crawler, $path, 'served' ); - $this->serve( $response['body'], $response['mode'], false ); - return; // Unreachable (serve exits); defensive. + return array( + 'serve' => true, + 'body' => $response['body'], + 'mode' => $response['mode'], + 'stale' => false, + 'reason' => 'fresh', + ); case 304: // Our cached body is current; CiteCue already counted this as served. - if ( $cached ) { - $cache->touch_page( $url ); - $this->plugin->activity->record( $crawler, $path, 'served' ); - $this->serve( $cached['body'], $cached['mode'], false ); + if ( ! $cached ) { + return self::pass( 'revalidated-without-cache' ); } - return; + $cache->touch_page( $url ); + $this->plugin->activity->record( $crawler, $path, 'served' ); + return array( + 'serve' => true, + 'body' => $cached['body'], + 'mode' => $cached['mode'], + 'stale' => false, + 'reason' => 'revalidated', + ); case 401: // Bad/revoked API key: remember it for the admin notice and // back off for a while — retrying immediately cannot help. update_option( 'citecue_auth_failed', time(), false ); $cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL ); - return; + return self::pass( 'unauthorized' ); case 404: // Miss sentinel: CiteCue recorded the passthrough hit @@ -157,19 +180,51 @@ public function maybe_serve() { $cache->delete_page( $url ); $cache->set_miss( $url ); $this->plugin->activity->record( $crawler, $path, 'passthrough' ); - return; + return self::pass( 'miss' ); default: // Unexpected server state: brief back-off, degrade gracefully. $cache->trip_circuit(); $this->plugin->activity->record( $crawler, $path, $cached ? 'served-stale' : 'error' ); - if ( $cached ) { - $this->serve( $cached['body'], $cached['mode'], true ); - } - return; + return $cached + ? self::serve_stale( $cached, 'server-error' ) + : self::pass( 'server-error' ); } } + /** + * A "leave this request to WordPress" decision. + * + * @param string $reason Why nothing is served (diagnostic only). + * @return array{serve:bool,body:string,mode:string,stale:bool,reason:string} + */ + private static function pass( $reason ) { + return array( + 'serve' => false, + 'body' => '', + 'mode' => '', + 'stale' => false, + 'reason' => $reason, + ); + } + + /** + * A "serve the cached body past its revalidation window" decision. + * + * @param array $cached Cache entry. + * @param string $reason Why the cached copy is being used (diagnostic only). + * @return array{serve:bool,body:string,mode:string,stale:bool,reason:string} + */ + private static function serve_stale( array $cached, $reason ) { + return array( + 'serve' => true, + 'body' => $cached['body'], + 'mode' => $cached['mode'], + 'stale' => true, + 'reason' => $reason, + ); + } + /** * Whether this request is one the proxy may intercept: a plain frontend * GET from an anonymous visitor. Everything else belongs to WordPress. @@ -216,30 +271,6 @@ private function is_eligible_request() { return true; } - /** - * Consumes one unit of the per-minute outbound-lookup budget. Bounds the - * delivery API calls an anonymous visitor can trigger by spoofing a - * crawler User-Agent across unique URLs; real crawl bursts beyond the - * budget just fall through to the normal page for the rest of the minute. - * - * @return bool Whether an API lookup may be made. - */ - private function consume_lookup_budget() { - /** - * Filters the maximum delivery API lookups per minute. - * - * @param int $limit Default 120. - */ - $limit = max( 1, (int) apply_filters( 'citecue_lookup_budget', 120 ) ); - $key = 'citecue_budget_' . (int) floor( time() / MINUTE_IN_SECONDS ); - $count = (int) get_transient( $key ); - if ( $count >= $limit ) { - return false; - } - set_transient( $key, $count + 1, 2 * MINUTE_IN_SECONDS ); - return true; - } - /** * WooCommerce requests the proxy must never touch: cart, checkout (incl. * order-pay / order-received), account pages and every other WC endpoint diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..8b30acf --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + ./tests/cases + + + + + ./includes + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..a76afc6 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,76 @@ +crawlers = new Citecue_Crawlers(); + } + + /** + * @dataProvider provide_crawler_agents + * + * @param string $user_agent User agent header. + * @param string $expected Expected matched token. + * @return void + */ + public function test_matches_known_crawlers( $user_agent, $expected ) { + $this->assertSame( $expected, $this->crawlers->match( $user_agent ) ); + } + + /** + * Real-world user agents for the crawlers the plugin serves. + * + * @return array + */ + public function provide_crawler_agents() { + return array( + 'GPTBot' => array( 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.2; +https://openai.com/gptbot', 'GPTBot' ), + 'ChatGPT-User' => array( 'Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)', 'ChatGPT-User' ), + 'OAI-SearchBot' => array( 'Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)', 'OAI-SearchBot' ), + 'ClaudeBot' => array( 'Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)', 'ClaudeBot' ), + 'Claude-User' => array( 'Mozilla/5.0 (compatible; Claude-User/1.0; +Claude-User@anthropic.com)', 'Claude-User' ), + 'PerplexityBot' => array( 'Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)', 'PerplexityBot' ), + 'CCBot' => array( 'CCBot/2.0 (https://commoncrawl.org/faq/)', 'CCBot' ), + 'Amazonbot' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Amazonbot/0.1', 'Amazonbot' ), + 'case insensitive' => array( 'mozilla/5.0 (compatible; gptbot/1.2)', 'GPTBot' ), + ); + } + + /** + * @dataProvider provide_non_crawler_agents + * + * @param string $user_agent User agent header. + * @return void + */ + public function test_ignores_everything_else( $user_agent ) { + $this->assertNull( $this->crawlers->match( $user_agent ) ); + } + + /** + * User agents that must never be served optimized content. + * + * @return array + */ + public function provide_non_crawler_agents() { + return array( + 'Chrome' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' ), + 'Safari on iOS' => array( 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1' ), + 'Googlebot' => array( 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' ), + 'Bingbot' => array( 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)' ), + 'empty' => array( '' ), + ); + } + + /** + * Google-Extended and Applebot-Extended are robots.txt directives, not + * fetchers — matching them would serve optimized content to nothing. + * + * @return void + */ + public function test_robots_txt_only_tokens_are_not_served() { + $this->assertNotContains( 'Google-Extended', Citecue_Crawlers::bundled_tokens() ); + $this->assertNotContains( 'Applebot-Extended', Citecue_Crawlers::bundled_tokens() ); + } + + /** + * When two tokens both appear in a UA the longer one must win, otherwise + * CiteCue is told the wrong bot fetched the page. + * + * @return void + */ + public function test_longest_token_wins() { + add_filter( + 'citecue_crawler_tokens', + static function () { + return array( 'Claude', 'Claude-SearchBot' ); + } + ); + + $this->assertSame( + 'Claude-SearchBot', + $this->crawlers->match( 'Mozilla/5.0 (compatible; Claude-SearchBot/1.0)' ) + ); + } + + /** + * @return void + */ + public function test_tokens_can_be_filtered() { + add_filter( + 'citecue_crawler_tokens', + static function ( $tokens ) { + $tokens[] = 'MyCustomAgent'; + return $tokens; + } + ); + + $this->assertSame( 'MyCustomAgent', $this->crawlers->match( 'MyCustomAgent/1.0' ) ); + } + + /** + * @return void + */ + public function test_match_can_be_overridden_per_request() { + add_filter( 'citecue_matched_crawler', '__return_null' ); + + $this->assertNull( $this->crawlers->match( 'GPTBot/1.2' ) ); + } + + /** + * A newer registry replaces the bundled list. + * + * @return void + */ + public function test_refresh_stores_a_newer_registry() { + $this->http->queue( + 'crawlers', + 200, + wp_json_encode( + array( + 'version' => 2, + 'tokens' => array( 'BrandNewBot' ), + ) + ) + ); + + $this->assertTrue( $this->crawlers->refresh( $this->plugin->api ) ); + $this->assertContains( 'BrandNewBot', $this->crawlers->get_tokens() ); + $this->assertSame( 2, $this->crawlers->registry_info()['version'] ); + } + + /** + * A truncated or downgraded feed must never be able to stop the plugin + * serving a crawler it already knows about. + * + * @return void + */ + public function test_refresh_keeps_the_bundled_tokens_as_a_floor() { + $this->http->queue( + 'crawlers', + 200, + wp_json_encode( + array( + 'version' => 2, + 'tokens' => array( 'BrandNewBot' ), + ) + ) + ); + $this->crawlers->refresh( $this->plugin->api ); + + foreach ( Citecue_Crawlers::bundled_tokens() as $token ) { + $this->assertContains( $token, $this->crawlers->get_tokens(), "Bundled token {$token} was dropped by a refresh." ); + } + $this->assertSame( 'GPTBot', $this->crawlers->match( 'GPTBot/1.2' ) ); + } + + /** + * @return void + */ + public function test_refresh_rejects_an_older_registry_version() { + $this->http->queue( + 'crawlers', + 200, + wp_json_encode( + array( + 'version' => 0, + 'tokens' => array( 'StaleBot' ), + ) + ) + ); + + $this->assertFalse( $this->crawlers->refresh( $this->plugin->api ) ); + $this->assertNotContains( 'StaleBot', $this->crawlers->get_tokens() ); + } + + /** + * @return void + */ + public function test_refresh_dedupes_case_insensitively() { + $this->http->queue( + 'crawlers', + 200, + wp_json_encode( + array( + 'version' => 2, + 'tokens' => array( 'gptbot', 'GPTBot' ), + ) + ) + ); + $this->crawlers->refresh( $this->plugin->api ); + + $lowercased = array_map( 'strtolower', $this->crawlers->get_tokens() ); + $this->assertSame( count( $lowercased ), count( array_unique( $lowercased ) ) ); + } + + /** + * A failing feed leaves the current list untouched. + * + * @return void + */ + public function test_refresh_survives_a_transport_error() { + $this->http->queue_error( 'crawlers' ); + + $this->assertFalse( $this->crawlers->refresh( $this->plugin->api ) ); + $this->assertSame( 'GPTBot', $this->crawlers->match( 'GPTBot/1.2' ) ); + } + + /** + * @return void + */ + public function test_refresh_rejects_a_junk_payload() { + $this->http->queue( 'crawlers', 200, 'not json' ); + + $this->assertFalse( $this->crawlers->refresh( $this->plugin->api ) ); + } + + /** + * The registry feed is public; sending the API key to it would leak the + * credential to an endpoint that does not need it. + * + * @return void + */ + public function test_registry_feed_is_requested_without_credentials() { + $this->configure_delivery(); + $this->http->queue( + 'crawlers', + 200, + wp_json_encode( + array( + 'version' => 2, + 'tokens' => array( 'BrandNewBot' ), + ) + ) + ); + $this->crawlers->refresh( $this->plugin->api ); + + $request = $this->http->last( 'crawlers' ); + $this->assertArrayNotHasKey( 'Authorization', $request['args']['headers'] ); + } +} diff --git a/tests/cases/test-ingest-auth.php b/tests/cases/test-ingest-auth.php new file mode 100644 index 0000000..6a946bf --- /dev/null +++ b/tests/cases/test-ingest-auth.php @@ -0,0 +1,320 @@ +plugin->settings->update( + array( + 'ingest_enabled' => true, + 'ingest_secret' => self::SECRET, + ) + ); + } + + /** + * @return void + */ + public function test_a_correctly_signed_push_is_accepted() { + $response = rest_do_request( $this->signed_push() ); + + $this->assertSame( 201, $response->get_status() ); + } + + /** + * @return void + */ + public function test_ingest_is_off_by_default() { + $this->plugin->settings->update( array( 'ingest_enabled' => false ) ); + + $this->assertPushRejected( 403, 'citecue_ingest_disabled', $this->signed_push() ); + } + + /** + * @return void + */ + public function test_a_missing_secret_is_rejected() { + $this->plugin->settings->update( array( 'ingest_secret' => '' ) ); + + $this->assertPushRejected( 403, 'citecue_no_secret', $this->signed_push() ); + } + + /** + * @return void + */ + public function test_an_unsigned_push_is_rejected() { + $request = $this->signed_push(); + $request->set_header( 'x-citecue-signature', '' ); + + $this->assertPushRejected( 401, 'citecue_bad_signature', $request ); + } + + /** + * @return void + */ + public function test_a_signature_without_the_algorithm_prefix_is_rejected() { + $request = $this->signed_push(); + $request->set_header( 'x-citecue-signature', hash_hmac( 'sha256', 'anything', self::SECRET ) ); + + $this->assertPushRejected( 401, 'citecue_bad_signature', $request ); + } + + /** + * @return void + */ + public function test_a_signature_from_the_wrong_secret_is_rejected() { + $request = $this->signed_push( array(), array( 'secret' => 'cws_the_wrong_secret' ) ); + + $this->assertPushRejected( 401, 'citecue_bad_signature', $request ); + } + + /** + * The signature covers the body, so an attacker cannot swap the payload of + * an otherwise valid request. + * + * @return void + */ + public function test_a_tampered_body_is_rejected() { + $request = $this->signed_push(); + $request->set_body( + wp_json_encode( + array( + 'external_id' => 'evil', + 'title' => 'Evil', + 'content' => '

evil

', + ) + ) + ); + + $this->assertPushRejected( 401, 'citecue_bad_signature', $request ); + } + + /** + * The signature also covers the timestamp, so a captured request cannot be + * re-dated to make it fresh again. + * + * @return void + */ + public function test_a_re_dated_signature_is_rejected() { + $request = $this->signed_push(); + $request->set_header( 'x-citecue-timestamp', (string) ( time() + 1 ) ); + + $this->assertPushRejected( 401, 'citecue_bad_signature', $request ); + } + + /** + * @dataProvider provide_stale_timestamps + * + * @param int $offset Seconds away from now. + * @return void + */ + public function test_timestamps_outside_the_window_are_rejected( $offset ) { + $request = $this->signed_push( array(), array( 'timestamp' => time() + $offset ) ); + + $this->assertPushRejected( 401, 'citecue_stale_timestamp', $request ); + } + + /** + * Timestamps that must fail the ±300s window. + * + * @return array + */ + public function provide_stale_timestamps() { + return array( + 'far in the past' => array( -3600 ), + 'just too old' => array( -301 ), + 'just too far off' => array( 301 ), + 'far in the future' => array( 3600 ), + 'missing entirely' => array( -( time() ) ), + ); + } + + /** + * @dataProvider provide_fresh_timestamps + * + * @param int $offset Seconds away from now. + * @return void + */ + public function test_timestamps_inside_the_window_are_accepted( $offset ) { + $request = $this->signed_push( + array( 'external_id' => 'window-' . $offset ), + array( 'timestamp' => time() + $offset ) + ); + + $this->assertSame( 201, rest_do_request( $request )->get_status() ); + } + + /** + * Clock skew inside the tolerated window. + * + * @return array + */ + public function provide_fresh_timestamps() { + return array( + 'now' => array( 0 ), + 'slightly behind' => array( -299 ), + 'slightly ahead' => array( 299 ), + ); + } + + /** + * A captured request replayed inside the freshness window must not be able + * to re-apply its effects — a replayed `force: true` push would silently + * overwrite an edit the site owner had just made. + * + * @return void + */ + public function test_a_replayed_signature_is_rejected() { + $request = $this->signed_push(); + $this->assertSame( 201, rest_do_request( $request )->get_status() ); + + $this->assertPushRejected( 401, 'citecue_replayed', $this->replay_of( $request ) ); + } + + /** + * Legitimate retries recompute the timestamp, so they keep working. + * + * @return void + */ + public function test_a_freshly_signed_retry_is_accepted() { + $this->assertSame( 201, rest_do_request( $this->signed_push() )->get_status() ); + + // Same payload, signed again a second later. + $retry = $this->signed_push( array(), array( 'timestamp' => time() + 1 ) ); + $this->assertSame( 200, rest_do_request( $retry )->get_status() ); + } + + /** + * @return void + */ + public function test_pushes_are_rate_limited() { + add_filter( 'citecue_ingest_rate_limit', static fn() => 2 ); + + $this->assertSame( 201, rest_do_request( $this->signed_push( array( 'external_id' => 'one' ) ) )->get_status() ); + $this->assertSame( 201, rest_do_request( $this->signed_push( array( 'external_id' => 'two' ) ) )->get_status() ); + + $this->assertPushRejected( 429, 'citecue_rate_limited', $this->signed_push( array( 'external_id' => 'three' ) ) ); + } + + /** + * The rate limit is checked after authentication on purpose: otherwise + * anyone could spend the budget with junk requests and lock CiteCue out. + * + * @return void + */ + public function test_unsigned_traffic_cannot_exhaust_the_rate_limit() { + add_filter( 'citecue_ingest_rate_limit', static fn() => 1 ); + + for ( $i = 0; $i < 20; $i++ ) { + $junk = $this->signed_push( array( 'external_id' => "junk-{$i}" ), array( 'secret' => 'wrong' ) ); + $this->assertSame( 401, rest_do_request( $junk )->get_status() ); + } + + $this->assertSame( 201, rest_do_request( $this->signed_push() )->get_status() ); + } + + /** + * The health endpoint is the install handshake, so it stays public — and + * must therefore never expose the shared secret or the API key. + * + * @return void + */ + public function test_health_is_public_and_leaks_nothing() { + $this->configure_delivery(); + + $response = rest_do_request( new WP_REST_Request( 'GET', '/citecue/v1/health' ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'citecue', $data['plugin'] ); + $this->assertTrue( $data['ingest'] ); + $this->assertTrue( $data['delivery'] ); + + $encoded = wp_json_encode( $data ); + $this->assertStringNotContainsString( self::SECRET, $encoded ); + $this->assertStringNotContainsString( self::API_KEY, $encoded ); + } + + /** + * Builds a signed push request. + * + * @param array $payload Body fields merged over a valid default. + * @param array $signing Optional 'secret' and 'timestamp' overrides. + * @return WP_REST_Request + */ + private function signed_push( array $payload = array(), array $signing = array() ) { + $body = wp_json_encode( + array_merge( + array( + 'external_id' => 'faq-pack-1', + 'title' => 'Acme FAQ', + 'content' => '

What is Acme?

A company.

', + ), + $payload + ) + ); + + $secret = isset( $signing['secret'] ) ? $signing['secret'] : self::SECRET; + $timestamp = isset( $signing['timestamp'] ) ? $signing['timestamp'] : time(); + + $request = new WP_REST_Request( 'POST', '/citecue/v1/content' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_header( 'x-citecue-timestamp', (string) $timestamp ); + $request->set_header( 'x-citecue-signature', 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $body, $secret ) ); + $request->set_body( $body ); + + return $request; + } + + /** + * An identical copy of a request, as a captured replay would be. + * + * @param WP_REST_Request $request Original request. + * @return WP_REST_Request + */ + private function replay_of( WP_REST_Request $request ) { + $replay = new WP_REST_Request( 'POST', '/citecue/v1/content' ); + $replay->set_headers( $request->get_headers() ); + $replay->set_body( $request->get_body() ); + + return $replay; + } + + /** + * Asserts a push was refused with a specific status and error code. + * + * @param int $status Expected HTTP status. + * @param string $code Expected WP_Error code. + * @param WP_REST_Request $request Request to send. + * @return void + */ + private function assertPushRejected( $status, $code, WP_REST_Request $request ) { + $response = rest_do_request( $request ); + + $this->assertSame( $status, $response->get_status() ); + $this->assertSame( $code, $response->get_data()['code'] ); + } +} diff --git a/tests/cases/test-ingest-content.php b/tests/cases/test-ingest-content.php new file mode 100644 index 0000000..5f7d831 --- /dev/null +++ b/tests/cases/test-ingest-content.php @@ -0,0 +1,627 @@ +plugin->settings->update( + array( + 'ingest_enabled' => true, + 'ingest_secret' => self::SECRET, + ) + ); + } + + /** + * @return void + */ + public function test_a_push_creates_a_draft_post() { + $response = $this->push( + array( + 'title' => 'Acme FAQ', + 'content' => '

What is Acme?

A company.

', + ) + ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertTrue( $response->get_data()['created'] ); + + $post = get_post( $response->get_data()['post_id'] ); + $this->assertSame( 'Acme FAQ', $post->post_title ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( 'post', $post->post_type ); + $this->assertStringContainsString( 'What is Acme?', $post->post_content ); + } + + /** + * Nothing CiteCue pushes is visible until a human says so — that is the + * default the plugin promises. + * + * @return void + */ + public function test_content_is_a_draft_even_when_publish_is_requested() { + $response = $this->push( array( 'status' => 'publish' ) ); + + $this->assertSame( 'draft', get_post_status( $response->get_data()['post_id'] ) ); + } + + /** + * @dataProvider provide_status_caps + * + * @param string $cap Configured maximum status. + * @param string $requested Requested status. + * @param string $expected Effective status. + * @return void + */ + public function test_status_is_capped_by_the_setting( $cap, $requested, $expected ) { + $this->plugin->settings->update( array( 'ingest_post_status' => $cap ) ); + + $response = $this->push( array( 'status' => $requested ) ); + + $this->assertSame( $expected, get_post_status( $response->get_data()['post_id'] ) ); + } + + /** + * Cap/request/result combinations. + * + * @return array + */ + public function provide_status_caps() { + return array( + 'draft cap holds publish' => array( 'draft', 'publish', 'draft' ), + 'draft cap holds pending' => array( 'draft', 'pending', 'draft' ), + 'pending cap allows pending' => array( 'pending', 'pending', 'pending' ), + 'pending cap holds publish' => array( 'pending', 'publish', 'pending' ), + 'pending cap allows draft' => array( 'pending', 'draft', 'draft' ), + 'publish cap allows publish' => array( 'publish', 'publish', 'publish' ), + 'publish cap allows draft' => array( 'publish', 'draft', 'draft' ), + 'unknown request falls to cap' => array( 'pending', 'nonsense', 'pending' ), + 'no request falls to cap' => array( 'pending', '', 'pending' ), + ); + } + + /** + * @return void + */ + public function test_a_second_push_updates_the_same_post() { + $first = $this->push( array( 'title' => 'First' ) ); + $id = $first->get_data()['post_id']; + + $second = $this->push( array( 'title' => 'Second' ), array( 'timestamp' => time() + 1 ) ); + + $this->assertSame( 200, $second->get_status() ); + $this->assertTrue( $second->get_data()['updated'] ); + $this->assertSame( $id, $second->get_data()['post_id'] ); + $this->assertSame( 'Second', get_post( $id )->post_title ); + $this->assertCount( + 1, + get_posts( + array( + 'post_status' => 'draft', + 'fields' => 'ids', + ) + ) + ); + } + + /** + * A different external_id is a different piece of content. + * + * @return void + */ + public function test_distinct_external_ids_create_distinct_posts() { + $one = $this->push( array( 'external_id' => 'one' ) ); + $two = $this->push( array( 'external_id' => 'two' ), array( 'timestamp' => time() + 1 ) ); + + $this->assertNotSame( $one->get_data()['post_id'], $two->get_data()['post_id'] ); + } + + /** + * Pushed HTML is untrusted input: it is stored, so it must be sanitized + * exactly like content from an editor without unfiltered_html. + * + * @return void + */ + public function test_dangerous_markup_is_stripped() { + $response = $this->push( + array( + 'content' => '

Hello

', + ) + ); + + $content = get_post( $response->get_data()['post_id'] )->post_content; + + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'onerror', $content ); + $this->assertStringContainsString( '

Hello

', $content ); + } + + /** + * @return void + */ + public function test_the_title_is_stored_as_plain_text() { + $response = $this->push( array( 'title' => 'Acme FAQ' ) ); + + $this->assertStringNotContainsString( 'get_data()['post_id'] )->post_title ); + } + + /** + * @dataProvider provide_incomplete_payloads + * + * @param array $payload Payload missing a required field. + * @return void + */ + public function test_incomplete_payloads_are_rejected( array $payload ) { + $response = $this->push( $payload ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'citecue_missing_fields', $response->get_data()['code'] ); + } + + /** + * Payloads that must not create anything. + * + * @return array + */ + public function provide_incomplete_payloads() { + return array( + 'no external_id' => array( array( 'external_id' => '' ) ), + 'no title' => array( array( 'title' => '' ) ), + 'no content' => array( array( 'content' => '' ) ), + 'whitespace content' => array( array( 'content' => " \n\t " ) ), + 'unusable external_id' => array( array( 'external_id' => '!!!/@@@' ) ), + ); + } + + /** + * External ids end up in a meta_value lookup, so they are reduced to a + * known-safe charset rather than rejected outright. + * + * @return void + */ + public function test_external_ids_are_sanitized() { + $response = $this->push( array( 'external_id' => '../../etc/passwd brief:1' ) ); + + $this->assertSame( + '....etcpasswdbrief:1', + get_post_meta( $response->get_data()['post_id'], '_citecue_external_id', true ) + ); + } + + /** + * @return void + */ + public function test_external_ids_are_length_capped() { + $response = $this->push( array( 'external_id' => str_repeat( 'a', 200 ) ) ); + + $this->assertSame( + 128, + strlen( get_post_meta( $response->get_data()['post_id'], '_citecue_external_id', true ) ) + ); + } + + /** + * @return void + */ + public function test_a_non_json_body_is_rejected() { + $body = 'this is not json'; + $timestamp = time(); + + $request = new WP_REST_Request( 'POST', '/citecue/v1/content' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_header( 'x-citecue-timestamp', (string) $timestamp ); + $request->set_header( 'x-citecue-signature', 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $body, self::SECRET ) ); + $request->set_body( $body ); + + $this->assertSame( 400, rest_do_request( $request )->get_status() ); + } + + /** + * If the site owner has edited a pushed post, the next push must not + * silently discard their work. + * + * @return void + */ + public function test_a_locally_edited_post_is_not_overwritten() { + $id = $this->push()->get_data()['post_id']; + + wp_update_post( + array( + 'ID' => $id, + 'post_content' => '

Rewritten by the site owner.

', + ) + ); + + $response = $this->push( array( 'content' => '

Pushed again.

' ), array( 'timestamp' => time() + 1 ) ); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'citecue_edited_locally', $response->get_data()['code'] ); + $this->assertStringContainsString( 'site owner', get_post( $id )->post_content ); + } + + /** + * …but an explicit force overrides it. + * + * @return void + */ + public function test_force_overwrites_a_locally_edited_post() { + $id = $this->push()->get_data()['post_id']; + wp_update_post( + array( + 'ID' => $id, + 'post_content' => '

Local edit.

', + ) + ); + + $response = $this->push( + array( + 'content' => '

Pushed again.

', + 'force' => true, + ), + array( 'timestamp' => time() + 1 ) + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertStringContainsString( 'Pushed again.', get_post( $id )->post_content ); + } + + /** + * An unchanged post is not an edited one — pushes must keep flowing. + * + * @return void + */ + public function test_an_untouched_post_keeps_accepting_pushes() { + $this->push(); + + $response = $this->push( array( 'content' => '

Updated copy.

' ), array( 'timestamp' => time() + 1 ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Throwing a pushed draft away is a decision: it must not be resurrected, + * and it must not come back as a duplicate either. + * + * @return void + */ + public function test_a_trashed_push_stays_trashed() { + $id = $this->push()->get_data()['post_id']; + wp_trash_post( $id ); + + $response = $this->push( array(), array( 'timestamp' => time() + 1 ) ); + + $this->assertSame( 410, $response->get_status() ); + $this->assertSame( 'citecue_trashed', $response->get_data()['code'] ); + $this->assertSame( 'trash', get_post_status( $id ) ); + $this->assertCount( + 0, + get_posts( + array( + 'post_status' => 'draft', + 'fields' => 'ids', + ) + ) + ); + } + + /** + * @return void + */ + public function test_changing_the_type_of_an_existing_push_is_refused() { + $this->push( array( 'type' => 'post' ) ); + + $response = $this->push( array( 'type' => 'page' ), array( 'timestamp' => time() + 1 ) ); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'citecue_type_conflict', $response->get_data()['code'] ); + } + + /** + * @return void + */ + public function test_pages_can_be_pushed() { + $response = $this->push( array( 'type' => 'page' ) ); + + $this->assertSame( 'page', get_post_type( $response->get_data()['post_id'] ) ); + } + + /** + * @return void + */ + public function test_an_unknown_type_falls_back_to_the_configured_default() { + $this->plugin->settings->update( array( 'ingest_post_type' => 'page' ) ); + + $response = $this->push( array( 'type' => 'attachment' ) ); + + $this->assertSame( 'page', get_post_type( $response->get_data()['post_id'] ) ); + } + + /** + * @return void + */ + public function test_products_are_refused_without_woocommerce() { + if ( class_exists( 'WooCommerce' ) ) { + $this->markTestSkipped( 'WooCommerce is loaded in this run.' ); + } + + $response = $this->push( array( 'type' => 'product' ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'citecue_woocommerce_missing', $response->get_data()['code'] ); + } + + /** + * A configured product default must degrade to a post if WooCommerce is + * deactivated, rather than failing every push. + * + * @return void + */ + public function test_a_stale_product_default_degrades_to_post() { + if ( class_exists( 'WooCommerce' ) ) { + $this->markTestSkipped( 'WooCommerce is loaded in this run.' ); + } + + update_option( + 'citecue_settings', + array_merge( Citecue_Settings::defaults(), array( 'ingest_post_type' => 'product' ) ) + ); + $this->reset_settings_cache(); + $this->plugin->settings->update( + array( + 'ingest_enabled' => true, + 'ingest_secret' => self::SECRET, + ) + ); + + $response = $this->push(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'post', get_post_type( $response->get_data()['post_id'] ) ); + } + + /** + * @return void + */ + public function test_categories_and_tags_are_created_and_assigned() { + $response = $this->push( + array( + 'categories' => array( 'AI Search' ), + 'tags' => array( 'llms', 'faq' ), + ) + ); + $id = $response->get_data()['post_id']; + + $this->assertSame( array( 'AI Search' ), wp_list_pluck( get_the_category( $id ), 'name' ) ); + $this->assertEqualSets( array( 'llms', 'faq' ), wp_list_pluck( wp_get_post_tags( $id ), 'name' ) ); + } + + /** + * @return void + */ + public function test_an_existing_category_is_reused_not_duplicated() { + $existing = self::factory()->category->create( array( 'name' => 'AI Search' ) ); + + $response = $this->push( array( 'categories' => array( 'AI Search' ) ) ); + + $this->assertSame( + array( $existing ), + wp_list_pluck( get_the_category( $response->get_data()['post_id'] ), 'term_id' ) + ); + } + + /** + * @return void + */ + public function test_optional_fields_are_stored() { + $response = $this->push( + array( + 'excerpt' => 'A short summary.', + 'slug' => 'acme-faq', + 'source' => 'faq_pack:opp_42', + 'meta_description' => 'Everything about Acme.', + ) + ); + $id = $response->get_data()['post_id']; + + $this->assertSame( 'A short summary.', get_post( $id )->post_excerpt ); + $this->assertSame( 'acme-faq', get_post( $id )->post_name ); + $this->assertSame( 'faq_pack:opp_42', get_post_meta( $id, '_citecue_source', true ) ); + $this->assertSame( 'Everything about Acme.', get_post_meta( $id, '_citecue_meta_description', true ) ); + $this->assertSame( 'faq-pack-1', get_post_meta( $id, '_citecue_external_id', true ) ); + } + + /** + * @return void + */ + public function test_pushed_posts_are_attributed_to_an_administrator() { + self::factory()->user->create( array( 'role' => 'administrator' ) ); + + $response = $this->push(); + $author = (int) get_post( $response->get_data()['post_id'] )->post_author; + + $this->assertSame( $this->oldest_administrator(), $author ); + $this->assertTrue( user_can( $author, 'edit_posts' ) ); + } + + /** + * @return void + */ + public function test_a_configured_author_is_used_when_it_can_edit_posts() { + $editor = self::factory()->user->create( array( 'role' => 'editor' ) ); + self::factory()->user->create( array( 'role' => 'administrator' ) ); + $this->plugin->settings->update( array( 'ingest_author' => $editor ) ); + + $response = $this->push(); + + $this->assertSame( $editor, (int) get_post( $response->get_data()['post_id'] )->post_author ); + } + + /** + * @return void + */ + public function test_a_configured_author_without_edit_rights_is_ignored() { + $subscriber = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + $this->plugin->settings->update( array( 'ingest_author' => $subscriber ) ); + + $response = $this->push(); + + $this->assertSame( + $this->oldest_administrator(), + (int) get_post( $response->get_data()['post_id'] )->post_author + ); + } + + /** + * The fallback author the plugin picks: the site's oldest administrator. + * + * @return int + */ + private function oldest_administrator() { + $admins = get_users( + array( + 'role' => 'administrator', + 'orderby' => 'ID', + 'order' => 'ASC', + 'number' => 1, + 'fields' => 'ID', + ) + ); + + return (int) $admins[0]; + } + + /** + * @return void + */ + public function test_the_postarr_filter_can_adjust_the_push() { + add_filter( + 'citecue_ingest_postarr', + static function ( $postarr ) { + $postarr['post_title'] = 'Filtered title'; + return $postarr; + } + ); + + $response = $this->push(); + + $this->assertSame( 'Filtered title', get_post( $response->get_data()['post_id'] )->post_title ); + } + + /** + * @return void + */ + public function test_the_response_describes_where_the_content_landed() { + $data = $this->push()->get_data(); + + $this->assertSame( 'draft', $data['status'] ); + $this->assertStringContainsString( 'post.php', $data['edit_link'] ); + $this->assertStringContainsString( (string) $data['post_id'], $data['edit_link'] ); + $this->assertNotEmpty( $data['permalink'] ); + } + + /** + * The meta description is printed for pushed content… + * + * @return void + */ + public function test_the_meta_description_is_printed_on_the_post() { + $id = $this->push( array( 'meta_description' => 'Everything about Acme.' ) )->get_data()['post_id']; + wp_publish_post( $id ); + $this->go_to( get_permalink( $id ) ); + + $this->assertStringContainsString( + '', + $this->render_head() + ); + } + + /** + * …and can be turned off, e.g. when an SEO plugin owns the tag. + * + * @return void + */ + public function test_the_meta_description_can_be_suppressed() { + $id = $this->push( array( 'meta_description' => 'Everything about Acme.' ) )->get_data()['post_id']; + wp_publish_post( $id ); + $this->go_to( get_permalink( $id ) ); + + add_filter( 'citecue_output_meta_description', '__return_false' ); + + $this->assertStringNotContainsString( 'render_head() ); + } + + /** + * @return void + */ + public function test_no_meta_description_is_printed_for_ordinary_posts() { + $id = self::factory()->post->create(); + $this->go_to( get_permalink( $id ) ); + + $this->assertStringNotContainsString( 'render_head() ); + } + + /** + * Captures what the plugin adds to wp_head. + * + * @return string + */ + private function render_head() { + ob_start(); + do_action( 'wp_head' ); + return (string) ob_get_clean(); + } + + /** + * Sends a signed push. + * + * @param array $payload Body fields merged over a valid default. + * @param array $signing Optional 'timestamp' override. + * @return WP_REST_Response + */ + private function push( array $payload = array(), array $signing = array() ) { + $body = wp_json_encode( + array_merge( + array( + 'external_id' => 'faq-pack-1', + 'title' => 'Acme FAQ', + 'content' => '

What is Acme?

A company.

', + ), + $payload + ) + ); + + $timestamp = isset( $signing['timestamp'] ) ? $signing['timestamp'] : time(); + + $request = new WP_REST_Request( 'POST', '/citecue/v1/content' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_header( 'x-citecue-timestamp', (string) $timestamp ); + $request->set_header( 'x-citecue-signature', 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $body, self::SECRET ) ); + $request->set_body( $body ); + + return rest_do_request( $request ); + } +} diff --git a/tests/cases/test-ingest-products.php b/tests/cases/test-ingest-products.php new file mode 100644 index 0000000..9d860d3 --- /dev/null +++ b/tests/cases/test-ingest-products.php @@ -0,0 +1,219 @@ +markTestSkipped( 'Requires a real WooCommerce (CITECUE_WITH_WOOCOMMERCE=1).' ); + } + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + + $this->plugin->settings->update( + array( + 'ingest_enabled' => true, + 'ingest_secret' => self::SECRET, + ) + ); + } + + /** + * @return void + */ + public function test_a_product_push_creates_a_draft_simple_product() { + $response = $this->push( + array( + 'type' => 'product', + 'title' => 'Acme Widget', + 'content' => '

A very good widget.

', + 'excerpt' => 'A good widget.', + 'sku' => 'ACME-1', + 'regular_price' => '19.99', + ) + ); + + $this->assertSame( 201, $response->get_status() ); + + $product = wc_get_product( $response->get_data()['post_id'] ); + + $this->assertInstanceOf( 'WC_Product_Simple', $product ); + $this->assertSame( 'Acme Widget', $product->get_name() ); + $this->assertSame( 'draft', $product->get_status() ); + $this->assertSame( 'ACME-1', $product->get_sku() ); + $this->assertSame( '19.99', $product->get_regular_price() ); + $this->assertStringContainsString( 'very good widget', $product->get_description() ); + $this->assertSame( 'A good widget.', $product->get_short_description() ); + } + + /** + * @return void + */ + public function test_product_terms_use_the_product_taxonomies() { + $response = $this->push( + array( + 'type' => 'product', + 'categories' => array( 'Widgets' ), + 'tags' => array( 'metal' ), + ) + ); + $id = $response->get_data()['post_id']; + + $this->assertSame( array( 'Widgets' ), wp_list_pluck( get_the_terms( $id, 'product_cat' ), 'name' ) ); + $this->assertSame( array( 'metal' ), wp_list_pluck( get_the_terms( $id, 'product_tag' ), 'name' ) ); + } + + /** + * The status cap applies to products exactly as it does to posts. + * + * @return void + */ + public function test_products_respect_the_status_cap() { + $response = $this->push( + array( + 'type' => 'product', + 'status' => 'publish', + ) + ); + + $this->assertSame( 'draft', get_post_status( $response->get_data()['post_id'] ) ); + } + + /** + * Adopting a product the plugin did not create replaces its description, + * so it must never happen by accident. + * + * @return void + */ + public function test_an_existing_sku_is_not_adopted_without_force() { + $existing = new WC_Product_Simple(); + $existing->set_name( 'Hand-written Widget' ); + $existing->set_description( '

Written by the store owner.

' ); + $existing->set_sku( 'ACME-1' ); + $existing->save(); + + $response = $this->push( + array( + 'type' => 'product', + 'sku' => 'ACME-1', + ) + ); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'citecue_sku_exists', $response->get_data()['code'] ); + $this->assertStringContainsString( + 'store owner', + wc_get_product( $existing->get_id() )->get_description() + ); + } + + /** + * With force, the adoption goes through and the product becomes a normal + * push target from then on. + * + * @return void + */ + public function test_force_adopts_an_existing_product_by_sku() { + $existing = new WC_Product_Simple(); + $existing->set_name( 'Hand-written Widget' ); + $existing->set_sku( 'ACME-1' ); + $existing->save(); + + $adopted = $this->push( + array( + 'type' => 'product', + 'sku' => 'ACME-1', + 'content' => '

Optimized copy.

', + 'force' => true, + ) + ); + + $this->assertSame( 200, $adopted->get_status() ); + $this->assertSame( $existing->get_id(), $adopted->get_data()['post_id'] ); + + // Subsequent pushes match on external_id, no SKU or force needed. + $update = $this->push( + array( + 'type' => 'product', + 'content' => '

Newer copy.

', + ), + array( 'timestamp' => time() + 1 ) + ); + + $this->assertSame( 200, $update->get_status() ); + $this->assertSame( $existing->get_id(), $update->get_data()['post_id'] ); + } + + /** + * @return void + */ + public function test_products_are_attributed_to_an_administrator() { + $response = $this->push( array( 'type' => 'product' ) ); + + $author = (int) get_post( $response->get_data()['post_id'] )->post_author; + + $this->assertTrue( user_can( $author, 'edit_posts' ) ); + } + + /** + * @return void + */ + public function test_the_health_endpoint_reports_woocommerce() { + $response = rest_do_request( new WP_REST_Request( 'GET', '/citecue/v1/health' ) ); + + $this->assertTrue( $response->get_data()['woocommerce'] ); + } + + /** + * Sends a signed push. + * + * @param array $payload Body fields merged over a valid default. + * @param array $signing Optional 'timestamp' override. + * @return WP_REST_Response + */ + private function push( array $payload = array(), array $signing = array() ) { + $body = wp_json_encode( + array_merge( + array( + 'external_id' => 'product-1', + 'title' => 'Acme Widget', + 'content' => '

A very good widget.

', + ), + $payload + ) + ); + + $timestamp = isset( $signing['timestamp'] ) ? $signing['timestamp'] : time(); + + $request = new WP_REST_Request( 'POST', '/citecue/v1/content' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_header( 'x-citecue-timestamp', (string) $timestamp ); + $request->set_header( 'x-citecue-signature', 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $body, self::SECRET ) ); + $request->set_body( $body ); + + return rest_do_request( $request ); + } +} diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php new file mode 100644 index 0000000..3724bc7 --- /dev/null +++ b/tests/cases/test-lifecycle.php @@ -0,0 +1,178 @@ +assertNotFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); + $this->assertSame( 'daily', wp_get_schedule( Citecue_Plugin::CRON_HOOK ) ); + } + + /** + * @return void + */ + public function test_activation_generates_an_ingest_secret() { + $this->plugin->settings->update( array( 'ingest_secret' => '' ) ); + + Citecue_Plugin::activate(); + $this->reset_settings_cache(); + + $secret = (string) $this->plugin->settings->get( 'ingest_secret' ); + + $this->assertStringStartsWith( 'cws_', $secret ); + $this->assertSame( 44, strlen( $secret ) ); + } + + /** + * Re-activating must not rotate the secret out from under CiteCue. + * + * @return void + */ + public function test_reactivation_keeps_the_existing_secret() { + Citecue_Plugin::activate(); + $this->reset_settings_cache(); + $first = $this->plugin->settings->get( 'ingest_secret' ); + + Citecue_Plugin::activate(); + $this->reset_settings_cache(); + + $this->assertSame( $first, $this->plugin->settings->get( 'ingest_secret' ) ); + } + + /** + * @return void + */ + public function test_activation_is_idempotent() { + Citecue_Plugin::activate(); + $first = wp_next_scheduled( Citecue_Plugin::CRON_HOOK ); + + Citecue_Plugin::activate(); + + $this->assertSame( $first, wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); + } + + /** + * @return void + */ + public function test_deactivation_clears_the_cron() { + Citecue_Plugin::activate(); + + Citecue_Plugin::deactivate(); + + $this->assertFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); + } + + /** + * A dropped schedule (a cron plugin, a botched migration) must heal itself + * rather than silently stop refreshing the crawler registry forever. + * + * @return void + */ + public function test_a_missing_cron_is_rescheduled_on_init() { + $this->assertFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); + + $this->plugin->on_init(); + + $this->assertNotFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); + } + + /** + * @return void + */ + public function test_the_daily_sync_refreshes_the_crawler_registry() { + $this->http->queue( + 'crawlers', + 200, + wp_json_encode( + array( + 'version' => 3, + 'tokens' => array( 'FutureBot' ), + ) + ) + ); + + $this->plugin->daily_sync(); + + $this->assertContains( 'FutureBot', $this->plugin->crawlers->get_tokens() ); + } + + /** + * A failing sync must not throw — it runs unattended on cron. + * + * @return void + */ + public function test_the_daily_sync_survives_an_outage() { + $this->http->queue_error( 'crawlers' ); + + $this->plugin->daily_sync(); + + $this->assertSame( 'GPTBot', $this->plugin->crawlers->match( 'GPTBot/1.2' ) ); + } + + /** + * Uninstall removes the plugin's own settings… + * + * @return void + */ + public function test_uninstall_removes_plugin_options() { + $this->configure_delivery(); + $this->plugin->activity->record( 'GPTBot', '/a/', 'served' ); + Citecue_Plugin::activate(); + + $this->run_uninstall(); + + $this->assertFalse( get_option( Citecue_Settings::OPTION ) ); + $this->assertFalse( get_option( Citecue_Activity_Log::OPTION ) ); + $this->assertFalse( get_option( Citecue_Crawlers::OPTION ) ); + $this->assertFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); + } + + /** + * …but content pushed by CiteCue belongs to the site, so it stays. + * + * @return void + */ + public function test_uninstall_keeps_pushed_content() { + $post_id = self::factory()->post->create( array( 'post_title' => 'Pushed brief' ) ); + update_post_meta( $post_id, '_citecue_external_id', 'brief-1' ); + + $this->run_uninstall(); + + $this->assertSame( 'Pushed brief', get_post( $post_id )->post_title ); + } + + /** + * Runs uninstall.php the way WordPress does. + * + * @return void + */ + private function run_uninstall() { + if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + define( 'WP_UNINSTALL_PLUGIN', 'citecue/citecue.php' ); + } + + require dirname( __DIR__, 2 ) . '/uninstall.php'; + } +} diff --git a/tests/cases/test-llms-txt.php b/tests/cases/test-llms-txt.php new file mode 100644 index 0000000..5e93913 --- /dev/null +++ b/tests/cases/test-llms-txt.php @@ -0,0 +1,322 @@ + An example site.\n"; + + /** + * Sets up a configured site requesting /llms.txt. + * + * @return void + */ + public function set_up() { + parent::set_up(); + $this->configure_delivery(); + $this->request_llms_txt(); + } + + /** + * Points the current request at /llms.txt. + * + * @return void + */ + private function request_llms_txt() { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/llms.txt'; + } + + /** + * @return void + */ + public function test_200_is_served_and_cached() { + $this->http->queue( 'llms', 200, self::BODY, array( 'etag' => '"v1"' ) ); + + $decision = $this->llms_txt()->decide(); + + $this->assertTrue( $decision['serve'] ); + $this->assertSame( self::BODY, $decision['body'] ); + $this->assertSame( self::BODY, $this->plugin->cache->get_llms_txt()['body'] ); + } + + /** + * The file is served to everyone, not only to AI crawlers. + * + * @return void + */ + public function test_it_is_served_to_human_visitors_too() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Macintosh) Chrome/126.0.0.0 Safari/537.36'; + $this->http->queue( 'llms', 200, self::BODY ); + + $this->assertTrue( $this->llms_txt()->decide()['serve'] ); + } + + /** + * @return void + */ + public function test_other_urls_are_left_alone() { + $_SERVER['REQUEST_URI'] = '/hello-world/'; + + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_non_get_requests_are_left_alone() { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_it_can_be_switched_off_in_settings() { + $this->configure_delivery( array( 'llms_txt_enabled' => false ) ); + + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * A fresh cached copy is served without touching the API — this endpoint + * is hit by every visitor, so it must not be an outbound call each time. + * + * @return void + */ + public function test_a_fresh_cached_copy_skips_the_api() { + $this->plugin->cache->set_llms_txt( self::BODY, '"v1"' ); + + $decision = $this->llms_txt()->decide(); + + $this->assertSame( self::BODY, $decision['body'] ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * Past the freshness window it revalidates with the stored ETag. + * + * @return void + */ + public function test_a_stale_cached_copy_is_revalidated() { + $this->stale_cache( self::BODY, '"v1"' ); + $this->http->queue( 'llms', 304 ); + + $decision = $this->llms_txt()->decide(); + + $this->assertSame( self::BODY, $decision['body'] ); + $this->assertSame( '"v1"', $this->http->last( 'llms' )['args']['headers']['If-None-Match'] ); + } + + /** + * @return void + */ + public function test_revalidation_refreshes_the_freshness_window() { + $this->stale_cache( self::BODY, '"v1"' ); + $this->http->queue( 'llms', 304 ); + $this->llms_txt()->decide(); + + // A second request inside the window must now be answered locally. + $this->llms_txt()->decide(); + + $this->assertSame( 1, $this->http->count( 'llms' ) ); + } + + /** + * @return void + */ + public function test_404_stops_serving_and_evicts_the_cached_copy() { + $this->stale_cache( self::BODY, '"v1"' ); + $this->http->queue( 'llms', 404 ); + + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + $this->assertNull( $this->plugin->cache->get_llms_txt() ); + } + + /** + * A project with llms.txt switched off must not turn every hit on the URL + * into an outbound call — this endpoint answers humans, not just crawlers. + * + * @return void + */ + public function test_404_is_negative_cached() { + $this->http->queue( 'llms', 404 ); + $this->llms_txt()->decide(); + + for ( $i = 0; $i < 10; $i++ ) { + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + } + + $this->assertSame( 1, $this->http->count( 'llms' ) ); + } + + /** + * @return void + */ + public function test_flushing_the_cache_retries_a_disabled_project() { + $this->http->queue( 'llms', 404 ); + $this->http->queue( 'llms', 200, self::BODY ); + + $this->llms_txt()->decide(); + $this->assertTrue( $this->plugin->cache->is_recent_llms_txt_miss() ); + + $this->plugin->cache->flush(); + + $this->assertSame( self::BODY, $this->llms_txt()->decide()['body'] ); + } + + /** + * @return void + */ + public function test_lookups_are_capped_by_the_budget() { + $this->exhaust_lookup_budget(); + + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * Degrading on an exhausted budget must not cost visitors a body the + * plugin already holds. + * + * @return void + */ + public function test_an_exhausted_budget_still_serves_a_stale_copy() { + $this->stale_cache( self::BODY, '"v1"' ); + $this->exhaust_lookup_budget(); + + $this->assertSame( self::BODY, $this->llms_txt()->decide()['body'] ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * The budget is one ceiling for the whole site, so traffic on one path + * cannot be used to get around the limit on the other. + * + * @return void + */ + public function test_the_budget_is_shared_with_the_crawler_path() { + add_filter( 'citecue_lookup_budget', static fn() => 1 ); + + $this->http->queue( 'llms', 404 ); + $this->llms_txt()->decide(); + + $this->set_permalink_structure( '/%postname%/' ); + $this->fake_crawler_request( '/hello-world/' ); + + $this->assertPassedThrough( 'budget-exhausted', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count( 'page' ) ); + } + + /** + * A served llms.txt spends budget too, so a flood on this URL is bounded + * even when CiteCue is answering normally. + * + * @return void + */ + public function test_serving_consumes_budget() { + add_filter( 'citecue_lookup_budget', static fn() => 2 ); + $this->http->queue( 'llms', 200, self::BODY ); + + // Each pass revalidates, because the cache is aged out every time. + for ( $i = 0; $i < 5; $i++ ) { + $this->stale_cache( self::BODY, '"v1"' ); + $this->llms_txt()->decide(); + } + + $this->assertSame( 2, $this->http->count( 'llms' ) ); + } + + /** + * @return void + */ + public function test_a_timeout_serves_the_cached_copy() { + $this->stale_cache( self::BODY, '"v1"' ); + $this->http->queue_error( 'llms' ); + + $this->assertSame( self::BODY, $this->llms_txt()->decide()['body'] ); + $this->assertTrue( $this->plugin->cache->is_circuit_open() ); + } + + /** + * With nothing cached there is no body to fall back to, so WordPress must + * answer — serving an empty llms.txt would look like a published, empty + * file. + * + * @return void + */ + public function test_a_timeout_without_a_cached_copy_passes_through() { + $this->http->queue_error( 'llms' ); + + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + } + + /** + * @return void + */ + public function test_a_500_body_is_never_served() { + $this->http->queue( 'llms', 500, 'Internal Server Error' ); + + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + } + + /** + * @return void + */ + public function test_401_records_the_auth_failure_and_backs_off() { + $this->http->queue( 'llms', 401 ); + + $this->assertFalse( $this->llms_txt()->decide()['serve'] ); + $this->assertNotFalse( get_option( 'citecue_auth_failed' ) ); + $this->assertTrue( $this->plugin->cache->is_circuit_open() ); + } + + /** + * @return void + */ + public function test_an_open_circuit_serves_the_cached_copy_without_calling_the_api() { + $this->stale_cache( self::BODY, '"v1"' ); + $this->plugin->cache->trip_circuit(); + + $this->assertSame( self::BODY, $this->llms_txt()->decide()['body'] ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * Caches an llms.txt body that is already past its freshness window. + * + * @param string $body llms.txt content. + * @param string $etag Cached ETag. + * @return void + */ + private function stale_cache( $body, $etag ) { + // Reading through the cache first forces the lazily generated key salt + // into existence, so the transient below lands on the key the plugin + // will look under. + $this->plugin->cache->get_llms_txt(); + + set_transient( + 'citecue_llms_' . get_option( 'citecue_cache_salt' ), + array( + 'body' => $body, + 'etag' => $etag, + 'cached_at' => time() - ( Citecue_Llms_Txt::FRESH_SECONDS + 1 ), + ), + DAY_IN_SECONDS + ); + } +} diff --git a/tests/cases/test-proxy-delivery.php b/tests/cases/test-proxy-delivery.php new file mode 100644 index 0000000..703c215 --- /dev/null +++ b/tests/cases/test-proxy-delivery.php @@ -0,0 +1,323 @@ +set_permalink_structure( '/%postname%/' ); + $this->configure_delivery(); + $this->factory->post->create( array( 'post_name' => 'hello-world' ) ); + $this->url = $this->fake_crawler_request( '/hello-world/' ); + } + + /** + * @return void + */ + public function test_200_is_served_and_cached() { + $this->http->queue( + 'page', + 200, + 'optimized', + array( + 'etag' => '"v1"', + 'x-citecue-mode' => 'enriched', + ) + ); + + $decision = $this->proxy()->decide(); + + $this->assertServed( 'optimized', $decision ); + $this->assertSame( 'enriched', $decision['mode'] ); + + $cached = $this->plugin->cache->get_page( $this->url ); + $this->assertSame( 'optimized', $cached['body'] ); + $this->assertSame( '"v1"', $cached['etag'] ); + $this->assertSame( 'enriched', $cached['mode'] ); + } + + /** + * The request carries the credentials, the channel marker and the crawler + * token — CiteCue attributes the hit from these. + * + * @return void + */ + public function test_delivery_request_is_addressed_correctly() { + $this->http->queue( 'page', 200, 'optimized' ); + $this->proxy()->decide(); + + $request = $this->http->last( 'page' ); + + $this->assertStringContainsString( 'k=' . self::PUBLIC_KEY, $request['url'] ); + $this->assertStringContainsString( 'b=GPTBot', $request['url'] ); + $this->assertStringContainsString( rawurlencode( $this->url ), $request['url'] ); + $this->assertSame( 'Bearer ' . self::API_KEY, $request['args']['headers']['Authorization'] ); + // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- the header value is literally lowercase. + $this->assertSame( 'wordpress', $request['args']['headers']['X-Citecue-Channel'] ); + } + + /** + * A second hit revalidates with the stored ETag instead of re-downloading. + * + * @return void + */ + public function test_304_serves_the_cached_body() { + $this->prime_page_cache( $this->url, 'cached', '"v1"', 'rewrite' ); + $this->http->queue( 'page', 304 ); + + $decision = $this->proxy()->decide(); + + $this->assertServed( 'cached', $decision ); + $this->assertSame( 'rewrite', $decision['mode'] ); + $this->assertSame( '"v1"', $this->http->last( 'page' )['args']['headers']['If-None-Match'] ); + } + + /** + * A 304 is a served hit, not a stale one — CiteCue already counted it. + * + * @return void + */ + public function test_304_is_not_flagged_stale() { + $this->prime_page_cache( $this->url, 'cached' ); + $this->http->queue( 'page', 304 ); + + $this->assertFalse( $this->proxy()->decide()['stale'] ); + } + + /** + * A 304 with nothing cached (evicted mid-flight) has no body to serve, so + * the theme must answer rather than the plugin emitting an empty page. + * + * @return void + */ + public function test_304_without_a_cached_body_passes_through() { + $this->http->queue( 'page', 304 ); + + $this->assertPassedThrough( 'revalidated-without-cache', $this->proxy()->decide() ); + } + + /** + * @return void + */ + public function test_404_passes_through_and_negative_caches() { + $this->http->queue( 'page', 404 ); + + $this->assertPassedThrough( 'miss', $this->proxy()->decide() ); + $this->assertTrue( $this->plugin->cache->is_recent_miss( $this->url ) ); + } + + /** + * A negative-cached URL must not hit the API again for a minute. + * + * @return void + */ + public function test_a_recent_miss_short_circuits_the_api() { + $this->http->queue( 'page', 404 ); + $this->proxy()->decide(); + + $this->assertPassedThrough( 'recent-miss', $this->proxy()->decide() ); + $this->assertSame( 1, $this->http->count( 'page' ) ); + } + + /** + * A page that was optimized and then removed in CiteCue must disappear + * here too — otherwise the stale-on-error path would resurrect content the + * site owner has already withdrawn. + * + * @return void + */ + public function test_404_evicts_a_previously_cached_body() { + $this->prime_page_cache( $this->url, 'withdrawn' ); + $this->http->queue( 'page', 404 ); + + $this->proxy()->decide(); + + $this->assertNull( $this->plugin->cache->get_page( $this->url ) ); + } + + /** + * @return void + */ + public function test_401_passes_through_and_records_the_auth_failure() { + $this->http->queue( 'page', 401 ); + + $this->assertPassedThrough( 'unauthorized', $this->proxy()->decide() ); + $this->assertNotFalse( get_option( 'citecue_auth_failed' ) ); + $this->assertTrue( $this->plugin->cache->is_circuit_open() ); + } + + /** + * A rejected key must not be retried on every crawler hit. + * + * @return void + */ + public function test_401_stops_further_api_calls() { + $this->http->queue( 'page', 401 ); + $this->proxy()->decide(); + + $this->fake_crawler_request( '/another-page/' ); + $this->proxy()->decide(); + + $this->assertSame( 1, $this->http->count( 'page' ) ); + } + + /** + * A recovered key clears the failure flag. + * + * @return void + */ + public function test_a_later_miss_clears_the_auth_failure_flag() { + update_option( 'citecue_auth_failed', time() ); + $this->http->queue( 'page', 404 ); + + $this->proxy()->decide(); + + $this->assertFalse( get_option( 'citecue_auth_failed' ) ); + } + + /** + * @return void + */ + public function test_a_timeout_passes_through_and_opens_the_circuit() { + $this->http->queue_error( 'page', 'cURL error 28: Operation timed out' ); + + $this->assertPassedThrough( 'transport-error', $this->proxy()->decide() ); + $this->assertTrue( $this->plugin->cache->is_circuit_open() ); + } + + /** + * A CiteCue outage must not cost the crawler the page it already had. + * + * @return void + */ + public function test_a_timeout_serves_the_stale_cached_body() { + $this->prime_page_cache( $this->url, 'cached' ); + $this->http->queue_error( 'page' ); + + $this->assertServed( 'cached', $this->proxy()->decide(), true ); + } + + /** + * @return void + */ + public function test_a_500_passes_through_and_opens_the_circuit() { + $this->http->queue( 'page', 500, 'upstream exploded' ); + + $this->assertPassedThrough( 'server-error', $this->proxy()->decide() ); + $this->assertTrue( $this->plugin->cache->is_circuit_open() ); + } + + /** + * An error body must never reach a crawler as if it were the page. + * + * @return void + */ + public function test_a_500_body_is_never_served() { + $this->http->queue( 'page', 500, 'Internal Server Error' ); + + $this->assertFalse( $this->proxy()->decide()['serve'] ); + } + + /** + * @return void + */ + public function test_a_500_serves_the_stale_cached_body() { + $this->prime_page_cache( $this->url, 'cached' ); + $this->http->queue( 'page', 500 ); + + $this->assertServed( 'cached', $this->proxy()->decide(), true ); + } + + /** + * @dataProvider provide_outcomes + * + * @param int $status Delivery API status. + * @param string $outcome Expected activity-log outcome. + * @return void + */ + public function test_outcomes_are_logged( $status, $outcome ) { + $this->http->queue( 'page', $status ); + $this->proxy()->decide(); + + $entries = $this->plugin->activity->entries(); + + $this->assertNotEmpty( $entries, "No activity entry recorded for a {$status}." ); + $this->assertSame( $outcome, $entries[0]['outcome'] ); + $this->assertSame( 'GPTBot', $entries[0]['crawler'] ); + $this->assertSame( '/hello-world/', $entries[0]['path'] ); + } + + /** + * Status/outcome pairs. + * + * @return array + */ + public function provide_outcomes() { + return array( + 'hit' => array( 200, 'served' ), + 'miss' => array( 404, 'passthrough' ), + 'server error' => array( 500, 'error' ), + ); + } + + /** + * @return void + */ + public function test_stale_serving_is_logged_separately() { + $this->prime_page_cache( $this->url, 'cached' ); + $this->http->queue_error( 'page' ); + + $this->proxy()->decide(); + + $this->assertSame( 'served-stale', $this->plugin->activity->entries()[0]['outcome'] ); + } + + /** + * A 401 is an install problem, not crawler activity — it belongs in the + * admin notice, not in the traffic log. + * + * @return void + */ + public function test_auth_failures_are_not_logged_as_traffic() { + $this->http->queue( 'page', 401 ); + $this->proxy()->decide(); + + $this->assertSame( array(), $this->plugin->activity->entries() ); + } + + /** + * The serving timeout stays short: a slow CiteCue must not hold a PHP + * worker open. + * + * @return void + */ + public function test_serve_timeout_defaults_to_three_seconds_and_is_filterable() { + $this->http->queue( 'page', 200, 'a' ); + $this->proxy()->decide(); + $this->assertSame( 3, $this->http->last( 'page' )['args']['timeout'] ); + + add_filter( 'citecue_serve_timeout', static fn() => 1 ); + $this->fake_crawler_request( '/another/' ); + $this->proxy()->decide(); + $this->assertSame( 1, $this->http->last( 'page' )['args']['timeout'] ); + } +} diff --git a/tests/cases/test-proxy-eligibility.php b/tests/cases/test-proxy-eligibility.php new file mode 100644 index 0000000..951d6ae --- /dev/null +++ b/tests/cases/test-proxy-eligibility.php @@ -0,0 +1,229 @@ +set_permalink_structure( '/%postname%/' ); + $this->configure_delivery(); + $this->factory->post->create( array( 'post_name' => 'hello-world' ) ); + } + + /** + * A plain crawler GET is the case everything else is measured against. + * + * @return void + */ + public function test_a_crawler_get_is_eligible() { + $this->fake_crawler_request(); + $this->http->queue( 'page', 200, 'optimized' ); + + $this->assertServed( 'optimized', $this->proxy()->decide() ); + } + + /** + * @return void + */ + public function test_human_traffic_is_never_intercepted() { + $this->fake_crawler_request( '/hello-world/', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0.0.0 Safari/537.36' ); + + $this->assertPassedThrough( 'not-a-crawler', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_non_get_requests_are_skipped() { + $this->fake_crawler_request(); + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_logged_in_users_are_skipped() { + $this->fake_crawler_request(); + wp_set_current_user( $this->factory->user->create( array( 'role' => 'editor' ) ) ); + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_admin_requests_are_skipped() { + $this->fake_crawler_request(); + set_current_screen( 'edit.php' ); + + $decision = $this->proxy()->decide(); + set_current_screen( 'front' ); + + $this->assertPassedThrough( 'not-eligible', $decision ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_feeds_are_skipped() { + $this->fake_crawler_request( '/feed/' ); + + $this->assertTrue( is_feed(), 'Test setup failed: /feed/ did not resolve to a feed request.' ); + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_robots_txt_is_skipped() { + $this->fake_crawler_request( '/robots.txt' ); + $GLOBALS['wp_query']->is_robots = true; + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_sitemaps_are_skipped() { + $this->fake_crawler_request( '/wp-sitemap.xml' ); + set_query_var( 'sitemap', 'index' ); + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_previews_are_skipped() { + $this->fake_crawler_request(); + $GLOBALS['wp_query']->is_preview = true; + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_embeds_are_skipped() { + $this->fake_crawler_request(); + $GLOBALS['wp_query']->is_embed = true; + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_ajax_requests_are_skipped() { + $this->fake_crawler_request(); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_cron_requests_are_skipped() { + $this->fake_crawler_request(); + add_filter( 'wp_doing_cron', '__return_true' ); + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_serving_can_be_switched_off_in_settings() { + $this->configure_delivery( array( 'serve_enabled' => false ) ); + $this->fake_crawler_request(); + + $this->assertPassedThrough( 'not-configured', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * Without credentials there is nothing to ask the API for — and no key to + * send it. + * + * @return void + */ + public function test_unconfigured_sites_never_call_the_api() { + $this->plugin->settings->update( + array( + 'api_key' => '', + 'public_key' => '', + ) + ); + $this->fake_crawler_request(); + + $this->assertPassedThrough( 'not-configured', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_a_site_can_veto_individual_requests() { + $this->fake_crawler_request(); + add_filter( 'citecue_should_serve', '__return_false' ); + + $this->assertPassedThrough( 'vetoed', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * The veto filter receives the matched crawler and the request URL so a + * site can make the decision per bot or per path. + * + * @return void + */ + public function test_veto_filter_receives_crawler_and_url() { + $url = $this->fake_crawler_request( '/hello-world/' ); + $captured = array(); + + add_filter( + 'citecue_should_serve', + static function ( $serve, $crawler, $request_url ) use ( &$captured ) { + $captured = array( $crawler, $request_url ); + return false; + }, + 10, + 3 + ); + + $this->proxy()->decide(); + + $this->assertSame( array( 'GPTBot', $url ), $captured ); + } +} diff --git a/tests/cases/test-proxy-resilience.php b/tests/cases/test-proxy-resilience.php new file mode 100644 index 0000000..dad54a6 --- /dev/null +++ b/tests/cases/test-proxy-resilience.php @@ -0,0 +1,202 @@ +set_permalink_structure( '/%postname%/' ); + $this->configure_delivery(); + $this->factory->post->create( array( 'post_name' => 'hello-world' ) ); + $this->url = $this->fake_crawler_request( '/hello-world/' ); + } + + /** + * With the circuit open the API is off-limits entirely — that is the whole + * point of opening it. + * + * @return void + */ + public function test_an_open_circuit_makes_no_api_calls() { + $this->plugin->cache->trip_circuit(); + + $this->assertPassedThrough( 'circuit-open', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * @return void + */ + public function test_an_open_circuit_still_serves_cached_content() { + $this->prime_page_cache( $this->url, 'cached' ); + $this->plugin->cache->trip_circuit(); + + $this->assertServed( 'cached', $this->proxy()->decide(), true ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * One timeout must not cost more than one slow request: the next crawler + * hit is answered locally. + * + * @return void + */ + public function test_one_timeout_protects_subsequent_requests() { + $this->http->queue_error( 'page' ); + $this->proxy()->decide(); + + $this->fake_crawler_request( '/second-page/' ); + $this->assertPassedThrough( 'circuit-open', $this->proxy()->decide() ); + $this->assertSame( 1, $this->http->count( 'page' ) ); + } + + /** + * A rejected key backs off for far longer than a transient error, since + * retrying it cannot help. + * + * @return void + */ + public function test_auth_failures_back_off_longer_than_transient_errors() { + $this->assertGreaterThan( Citecue_Cache::CIRCUIT_TTL, Citecue_Cache::AUTH_CIRCUIT_TTL ); + } + + /** + * @return void + */ + public function test_the_circuit_closes_when_it_expires() { + $this->plugin->cache->trip_circuit(); + $this->assertTrue( $this->plugin->cache->is_circuit_open() ); + + delete_transient( 'citecue_circuit' ); + + $this->http->queue( 'page', 200, 'back' ); + $this->assertServed( 'back', $this->proxy()->decide() ); + } + + /** + * @return void + */ + public function test_flushing_the_cache_closes_the_circuit_and_drops_bodies() { + $this->prime_page_cache( $this->url, 'cached' ); + $this->plugin->cache->trip_circuit(); + + $this->plugin->cache->flush(); + + $this->assertFalse( $this->plugin->cache->is_circuit_open() ); + $this->assertNull( $this->plugin->cache->get_page( $this->url ) ); + } + + /** + * @return void + */ + public function test_an_exhausted_budget_stops_api_calls() { + $this->exhaust_lookup_budget(); + + $this->assertPassedThrough( 'budget-exhausted', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * Degrading on budget exhaustion must not degrade content the plugin + * already holds. + * + * @return void + */ + public function test_an_exhausted_budget_still_serves_cached_content() { + $this->prime_page_cache( $this->url, 'cached' ); + $this->exhaust_lookup_budget(); + + $this->assertServed( 'cached', $this->proxy()->decide(), true ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * The budget caps total outbound calls per minute regardless of how many + * distinct URLs a spoofed user agent asks for. + * + * @return void + */ + public function test_a_spoofed_crawler_cannot_spray_unbounded_lookups() { + add_filter( 'citecue_lookup_budget', static fn() => 3 ); + $this->http->queue( 'page', 404 ); + + for ( $i = 0; $i < 10; $i++ ) { + $this->fake_crawler_request( "/spray-{$i}/" ); + $this->proxy()->decide(); + } + + $this->assertSame( 3, $this->http->count( 'page' ) ); + } + + /** + * Budget spent on a URL is not spent again when the negative cache can + * answer — the two mechanisms compose. + * + * @return void + */ + public function test_the_negative_cache_conserves_budget() { + add_filter( 'citecue_lookup_budget', static fn() => 3 ); + $this->http->queue( 'page', 404 ); + + for ( $i = 0; $i < 10; $i++ ) { + $this->fake_crawler_request( '/same-page/' ); + $this->proxy()->decide(); + } + + $this->assertSame( 1, $this->http->count( 'page' ) ); + } + + /** + * Tracking-parameter noise on the same page shares one cache entry, so it + * cannot be used to walk around the negative cache. + * + * @return void + */ + public function test_tracking_parameters_do_not_multiply_lookups() { + $this->http->queue( 'page', 404 ); + + foreach ( array( '?utm_source=a', '?utm_source=b', '?fbclid=c', '' ) as $suffix ) { + $this->fake_crawler_request( '/tracked/' . $suffix ); + $this->proxy()->decide(); + } + + $this->assertSame( 1, $this->http->count( 'page' ) ); + } + + /** + * @return void + */ + public function test_budget_is_filterable() { + add_filter( 'citecue_lookup_budget', static fn() => 1 ); + $this->http->queue( 'page', 404 ); + + $this->fake_crawler_request( '/first/' ); + $this->proxy()->decide(); + $this->fake_crawler_request( '/second/' ); + + $this->assertPassedThrough( 'budget-exhausted', $this->proxy()->decide() ); + } +} diff --git a/tests/cases/test-settings.php b/tests/cases/test-settings.php new file mode 100644 index 0000000..5baca80 --- /dev/null +++ b/tests/cases/test-settings.php @@ -0,0 +1,265 @@ +settings = new Citecue_Settings(); + } + + /** + * Serving is on by default, ingest is not — pushing content into someone's + * site is opt-in. + * + * @return void + */ + public function test_defaults_are_safe() { + $defaults = Citecue_Settings::defaults(); + + $this->assertTrue( $defaults['serve_enabled'] ); + $this->assertTrue( $defaults['llms_txt_enabled'] ); + $this->assertFalse( $defaults['ingest_enabled'] ); + $this->assertSame( 'draft', $defaults['ingest_post_status'] ); + $this->assertSame( '', $defaults['api_key'] ); + $this->assertSame( '', $defaults['ingest_secret'] ); + } + + /** + * @return void + */ + public function test_delivery_needs_both_keys() { + $this->assertFalse( $this->settings->is_delivery_configured() ); + + $this->settings->update( array( 'api_key' => 'ck_live_x' ) ); + $this->assertFalse( $this->settings->is_delivery_configured() ); + + $this->settings->update( array( 'public_key' => 'pk_x' ) ); + $this->assertTrue( $this->settings->is_delivery_configured() ); + } + + /** + * Re-saving the settings form posts an empty key field (the real key is + * never rendered), so an empty value must mean "unchanged", not "erase". + * + * @return void + */ + public function test_an_empty_api_key_field_keeps_the_stored_key() { + $this->settings->update( array( 'api_key' => 'ck_live_keepme' ) ); + + $out = $this->settings->sanitize( array( 'api_key' => '' ) ); + + $this->assertSame( 'ck_live_keepme', $out['api_key'] ); + } + + /** + * Clearing it is possible, but only explicitly. + * + * @return void + */ + public function test_the_api_key_can_be_cleared_explicitly() { + $this->settings->update( array( 'api_key' => 'ck_live_keepme' ) ); + + $out = $this->settings->sanitize( array( 'api_key_clear' => '1' ) ); + + $this->assertSame( '', $out['api_key'] ); + } + + /** + * A new key may fix a previous auth failure, so the back-off must lift + * immediately rather than locking serving out for another ten minutes. + * + * @return void + */ + public function test_changing_the_api_key_clears_the_auth_backoff() { + $this->settings->update( array( 'api_key' => 'ck_live_old' ) ); + update_option( 'citecue_auth_failed', time() ); + $this->plugin->cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL ); + + $this->settings->sanitize( array( 'api_key' => 'ck_live_new' ) ); + + $this->assertFalse( get_option( 'citecue_auth_failed' ) ); + $this->assertFalse( $this->plugin->cache->is_circuit_open() ); + } + + /** + * @return void + */ + public function test_an_unchanged_api_key_leaves_the_backoff_alone() { + $this->settings->update( array( 'api_key' => 'ck_live_same' ) ); + update_option( 'citecue_auth_failed', time() ); + + $this->settings->sanitize( array( 'api_key' => 'ck_live_same' ) ); + + $this->assertNotFalse( get_option( 'citecue_auth_failed' ) ); + } + + /** + * Checkboxes are absent from the POST when unticked. + * + * @return void + */ + public function test_absent_checkboxes_mean_off() { + $this->settings->update( + array( + 'serve_enabled' => true, + 'llms_txt_enabled' => true, + 'ingest_enabled' => true, + ) + ); + + $out = $this->settings->sanitize( array() ); + + $this->assertFalse( $out['serve_enabled'] ); + $this->assertFalse( $out['llms_txt_enabled'] ); + $this->assertFalse( $out['ingest_enabled'] ); + } + + /** + * @return void + */ + public function test_an_invalid_status_cap_is_ignored() { + $out = $this->settings->sanitize( array( 'ingest_post_status' => 'publish_everything' ) ); + + $this->assertSame( 'draft', $out['ingest_post_status'] ); + } + + /** + * @return void + */ + public function test_a_valid_status_cap_is_stored() { + $out = $this->settings->sanitize( array( 'ingest_post_status' => 'pending' ) ); + + $this->assertSame( 'pending', $out['ingest_post_status'] ); + } + + /** + * @return void + */ + public function test_product_is_not_a_valid_type_without_woocommerce() { + if ( class_exists( 'WooCommerce' ) ) { + $this->markTestSkipped( 'WooCommerce (or its stub) is loaded.' ); + } + + $out = $this->settings->sanitize( array( 'ingest_post_type' => 'product' ) ); + + $this->assertSame( 'post', $out['ingest_post_type'] ); + } + + /** + * The plugin's own writes (a regenerated ingest secret, a project domain + * resolved from the API) go through the same callback, so they have to + * survive it. + * + * @return void + */ + public function test_internal_fields_pass_through_sanitation() { + $out = $this->settings->sanitize( + array( + 'ingest_secret' => 'cws_generated_internally', + 'project_domain' => 'example.org', + ) + ); + + $this->assertSame( 'cws_generated_internally', $out['ingest_secret'] ); + $this->assertSame( 'example.org', $out['project_domain'] ); + } + + /** + * Selecting a project fills in its domain from the cached project list, so + * the settings screen can show which site the key is pointed at. + * + * @return void + */ + public function test_selecting_a_project_fills_in_its_domain() { + update_option( + 'citecue_projects_cache', + array( + array( + 'publicKey' => 'pk_one', + 'domain' => 'one.example', + ), + array( + 'publicKey' => 'pk_two', + 'domain' => 'two.example', + ), + ) + ); + + $out = $this->settings->sanitize( array( 'public_key' => 'pk_two' ) ); + + $this->assertSame( 'two.example', $out['project_domain'] ); + } + + /** + * @return void + */ + public function test_clearing_the_project_clears_its_domain() { + $this->settings->update( array( 'project_domain' => 'one.example' ) ); + + $out = $this->settings->sanitize( array( 'public_key' => '' ) ); + + $this->assertSame( '', $out['project_domain'] ); + } + + /** + * @return void + */ + public function test_the_api_base_falls_back_to_the_default() { + $out = $this->settings->sanitize( array( 'api_base' => '' ) ); + + $this->assertSame( Citecue_Settings::DEFAULT_API_BASE, $out['api_base'] ); + } + + /** + * @return void + */ + public function test_the_api_base_loses_its_trailing_slash() { + $out = $this->settings->sanitize( array( 'api_base' => 'https://staging.citecue.test/' ) ); + + $this->assertSame( 'https://staging.citecue.test', $out['api_base'] ); + } + + /** + * A stored base with a trailing slash would produce double-slashed + * endpoints, so the accessor normalizes it too. + * + * @return void + */ + public function test_the_api_base_accessor_normalizes_a_stored_slash() { + $this->settings->update( array( 'api_base' => 'https://staging.citecue.test/' ) ); + + $this->assertSame( 'https://staging.citecue.test', $this->settings->api_base() ); + } + + /** + * @return void + */ + public function test_an_ingest_secret_is_generated_once_and_kept() { + $first = $this->settings->ensure_ingest_secret(); + + $this->assertStringStartsWith( 'cws_', $first ); + $this->assertSame( $first, $this->settings->ensure_ingest_secret() ); + } +} diff --git a/tests/cases/test-url-normalization.php b/tests/cases/test-url-normalization.php new file mode 100644 index 0000000..0c07d8e --- /dev/null +++ b/tests/cases/test-url-normalization.php @@ -0,0 +1,83 @@ +assertSame( $expected, Citecue_Cache::normalize_url( $input ) ); + } + + /** + * URL/expected-key pairs. + * + * @return array + */ + public function provide_urls() { + return array( + 'scheme is dropped' => array( 'https://example.org/about', 'example.org/about' ), + 'http and https agree' => array( 'http://example.org/about', 'example.org/about' ), + 'www is dropped' => array( 'https://www.example.org/about', 'example.org/about' ), + 'host is lowercased' => array( 'https://EXAMPLE.org/about', 'example.org/about' ), + 'trailing slash is dropped' => array( 'https://example.org/about/', 'example.org/about' ), + 'bare root has no path' => array( 'https://example.org/', 'example.org' ), + 'root without slash' => array( 'https://example.org', 'example.org' ), + 'fragment is dropped' => array( 'https://example.org/about#team', 'example.org/about' ), + 'utm params are dropped' => array( 'https://example.org/about?utm_source=chatgpt', 'example.org/about' ), + 'all trackers dropped' => array( 'https://example.org/a?utm_medium=x&ref=y&fbclid=z&gclid=w', 'example.org/a' ), + 'real params are kept' => array( 'https://example.org/shop?page=2', 'example.org/shop?page=2' ), + 'trackers stripped, rest kept' => array( 'https://example.org/shop?page=2&utm_source=x', 'example.org/shop?page=2' ), + 'unparseable input passes' => array( 'not a url', 'not a url' ), + 'empty input passes' => array( '', '' ), + ); + } + + /** + * A spoofed crawler cannot mint unlimited cache keys by varying tracking + * parameters — that is what bounds the lookup budget's usefulness. + * + * @return void + */ + public function test_tracking_noise_collapses_to_one_key() { + $keys = array_map( + array( 'Citecue_Cache', 'normalize_url' ), + array( + 'https://example.org/post/', + 'https://www.example.org/post', + 'http://EXAMPLE.ORG/post/?utm_source=a', + 'https://example.org/post?fbclid=123#top', + ) + ); + + $this->assertCount( 1, array_unique( $keys ) ); + } + + /** + * Different pages must not collide. + * + * @return void + */ + public function test_distinct_pages_get_distinct_keys() { + $this->assertNotSame( + Citecue_Cache::normalize_url( 'https://example.org/a' ), + Citecue_Cache::normalize_url( 'https://example.org/b' ) + ); + } +} diff --git a/tests/cases/test-woocommerce-exclusions.php b/tests/cases/test-woocommerce-exclusions.php new file mode 100644 index 0000000..b46e64c --- /dev/null +++ b/tests/cases/test-woocommerce-exclusions.php @@ -0,0 +1,136 @@ +set_permalink_structure( '/%postname%/' ); + $this->configure_delivery(); + $this->factory->post->create( array( 'post_name' => 'a-product' ) ); + } + + /** + * @dataProvider provide_store_pages + * + * @param string $page Stubbed WooCommerce page. + * @return void + */ + public function test_store_pages_are_never_intercepted( $page ) { + $this->requires_stub(); + + $this->fake_crawler_request( '/checkout/' ); + Citecue_Woocommerce_Stub::pretend( $page ); + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + } + + /** + * The WooCommerce pages that are off-limits. + * + * @return array + */ + public function provide_store_pages() { + return array( + 'cart' => array( 'cart' ), + 'checkout' => array( 'checkout' ), + 'account' => array( 'account' ), + 'WC endpoint' => array( 'endpoint' ), + ); + } + + /** + * `?add-to-cart=` mutates the cart, so it is not a page view at all. + * + * @return void + */ + public function test_add_to_cart_links_are_skipped() { + $this->requires_stub(); + + $this->fake_crawler_request( '/a-product/?add-to-cart=42' ); + $_GET['add-to-cart'] = '42'; + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + + unset( $_GET['add-to-cart'] ); + } + + /** + * @return void + */ + public function test_wc_ajax_calls_are_skipped() { + $this->requires_stub(); + + $this->fake_crawler_request( '/?wc-ajax=get_refreshed_fragments' ); + $_GET['wc-ajax'] = 'get_refreshed_fragments'; + + $this->assertPassedThrough( 'not-eligible', $this->proxy()->decide() ); + $this->assertSame( 0, $this->http->count() ); + + unset( $_GET['wc-ajax'] ); + } + + /** + * Product pages are the highest-value AI-crawler target on a store — the + * exclusions must not swallow them. + * + * @return void + */ + public function test_product_pages_are_still_served() { + $this->requires_stub(); + + $this->fake_crawler_request( '/a-product/' ); + $this->http->queue( 'page', 200, 'optimized product' ); + + $this->assertServed( 'optimized product', $this->proxy()->decide() ); + } + + /** + * Without WooCommerce the exclusions are inert — a store URL on a + * non-store site is just a page. + * + * @return void + */ + public function test_exclusions_do_nothing_without_woocommerce() { + if ( class_exists( 'WooCommerce' ) ) { + $this->markTestSkipped( 'WooCommerce (or its stub) is loaded.' ); + } + + $this->fake_crawler_request( '/cart/' ); + $this->http->queue( 'page', 200, 'just a page' ); + + $this->assertServed( 'just a page', $this->proxy()->decide() ); + } + + /** + * These tests drive WooCommerce's conditional tags directly. With a real + * WooCommerce loaded that would mean building carts and sessions, which + * tests WooCommerce rather than this plugin — the dedicated WooCommerce CI + * job covers the real integration through the ingest tests instead. + * + * @return void + */ + private function requires_stub() { + if ( ! Citecue_Woocommerce_Stub::is_active() ) { + $this->markTestSkipped( 'Run with CITECUE_STUB_WOOCOMMERCE=1 (or without a real WooCommerce loaded).' ); + } + } +} diff --git a/tests/includes/class-citecue-http-mock.php b/tests/includes/class-citecue-http-mock.php new file mode 100644 index 0000000..f1b121b --- /dev/null +++ b/tests/includes/class-citecue-http-mock.php @@ -0,0 +1,218 @@ + + */ + private $queues = array(); + + /** + * Recorded outbound requests. + * + * @var array + */ + private $requests = array(); + + /** + * Whether the filter is currently attached. + * + * @var bool + */ + private $enabled = false; + + /** + * Attaches the intercept. + * + * @return void + */ + public function enable() { + if ( ! $this->enabled ) { + add_filter( 'pre_http_request', array( $this, 'intercept' ), 10, 3 ); + $this->enabled = true; + } + } + + /** + * Detaches the intercept and drops all state. + * + * @return void + */ + public function disable() { + remove_filter( 'pre_http_request', array( $this, 'intercept' ), 10 ); + $this->enabled = false; + $this->queues = array(); + $this->requests = array(); + } + + /** + * Queues a response for an endpoint. + * + * @param string $endpoint One of page|llms|config|crawlers. + * @param int $status HTTP status code. + * @param string $body Response body. + * @param array $headers Response headers (case-insensitive). + * @return $this + */ + public function queue( $endpoint, $status, $body = '', array $headers = array() ) { + $this->queues[ $endpoint ][] = array( + 'status' => (int) $status, + 'body' => (string) $body, + 'headers' => $headers, + ); + return $this; + } + + /** + * Queues a transport failure (timeout, DNS error, refused connection). + * + * @param string $endpoint One of page|llms|config|crawlers. + * @param string $message Error message. + * @return $this + */ + public function queue_error( $endpoint, $message = 'Operation timed out' ) { + $this->queues[ $endpoint ][] = new WP_Error( 'http_request_failed', $message ); + return $this; + } + + /** + * The recorded requests, optionally filtered to one endpoint. + * + * @param string|null $endpoint Endpoint name, or null for all. + * @return array + */ + public function requests( $endpoint = null ) { + if ( null === $endpoint ) { + return $this->requests; + } + return array_values( + array_filter( + $this->requests, + static function ( $request ) use ( $endpoint ) { + return $request['endpoint'] === $endpoint; + } + ) + ); + } + + /** + * How many requests were made (optionally to one endpoint). + * + * @param string|null $endpoint Endpoint name, or null for all. + * @return int + */ + public function count( $endpoint = null ) { + return count( $this->requests( $endpoint ) ); + } + + /** + * The most recent request to an endpoint, or null. + * + * @param string $endpoint Endpoint name. + * @return array|null + */ + public function last( $endpoint ) { + $matching = $this->requests( $endpoint ); + return $matching ? end( $matching ) : null; + } + + /** + * Intercepts `wp_remote_*` and answers from the queue. + * + * @param mixed $preempt Short-circuit value. + * @param array $args Request arguments. + * @param string $url Request URL. + * @return array|WP_Error + * @throws RuntimeException When the code under test makes an unexpected call. + */ + public function intercept( $preempt, $args, $url ) { + $endpoint = self::classify( $url ); + + $this->requests[] = array( + 'endpoint' => $endpoint, + 'url' => $url, + 'args' => $args, + ); + + if ( empty( $this->queues[ $endpoint ] ) ) { + throw new RuntimeException( "Unexpected outbound request to [{$endpoint}] {$url}" ); + } + + // Keep the final queued response in place so repeated calls keep + // getting it; earlier entries are consumed one at a time. + $response = count( $this->queues[ $endpoint ] ) > 1 + ? array_shift( $this->queues[ $endpoint ] ) + : $this->queues[ $endpoint ][0]; + + if ( is_wp_error( $response ) ) { + return $response; + } + + return array( + 'headers' => self::headers( $response['headers'] ), + 'body' => $response['body'], + 'response' => array( + 'code' => $response['status'], + 'message' => get_status_header_desc( $response['status'] ), + ), + 'cookies' => array(), + 'filename' => null, + ); + } + + /** + * Maps a request URL to the endpoint it belongs to. + * + * @param string $url Request URL. + * @return string + */ + private static function classify( $url ) { + if ( false !== strpos( $url, '/api/delivery/v2/page' ) ) { + return 'page'; + } + if ( false !== strpos( $url, '/api/delivery/v2/llms.txt' ) ) { + return 'llms'; + } + if ( false !== strpos( $url, '/api/delivery/v2/config' ) ) { + return 'config'; + } + if ( false !== strpos( $url, '/api/delivery/v1/crawlers' ) ) { + return 'crawlers'; + } + return 'other'; + } + + /** + * Wraps headers the way the HTTP API really does, so header lookups in the + * code under test are exercised case-insensitively. + * + * @param array $headers Header map. + * @return \WpOrg\Requests\Utility\CaseInsensitiveDictionary|array + */ + private static function headers( array $headers ) { + if ( class_exists( '\WpOrg\Requests\Utility\CaseInsensitiveDictionary' ) ) { + return new \WpOrg\Requests\Utility\CaseInsensitiveDictionary( $headers ); + } + return $headers; + } +} diff --git a/tests/includes/class-citecue-test-case.php b/tests/includes/class-citecue-test-case.php new file mode 100644 index 0000000..68e60a7 --- /dev/null +++ b/tests/includes/class-citecue-test-case.php @@ -0,0 +1,196 @@ +server_backup = $_SERVER; + $this->plugin = Citecue_Plugin::instance(); + + // WP_UnitTestCase rolls the database back between tests, but the + // settings object caches its values for the request and transients may + // live in a persistent object cache — both would leak across tests. + $this->reset_settings_cache(); + wp_cache_flush(); + + $this->http = new Citecue_Http_Mock(); + $this->http->enable(); + + Citecue_Woocommerce_Stub::reset(); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['HTTP_HOST'] = 'example.org'; + $_SERVER['REQUEST_URI'] = '/'; + unset( $_SERVER['HTTP_USER_AGENT'] ); + } + + /** + * Tears the fakes back down. + * + * @return void + */ + public function tear_down() { + $this->http->disable(); + Citecue_Woocommerce_Stub::reset(); + $_SERVER = $this->server_backup; + + parent::tear_down(); + } + + /** + * Drops the per-request settings cache so the next read sees the database. + * + * @return void + */ + protected function reset_settings_cache() { + $values = new ReflectionProperty( 'Citecue_Settings', 'values' ); + $values->setAccessible( true ); + $values->setValue( $this->plugin->settings, null ); + } + + /** + * Gives the plugin a working delivery configuration. + * + * @param array $overrides Settings to override. + * @return void + */ + protected function configure_delivery( array $overrides = array() ) { + $this->plugin->settings->update( + array_merge( + array( + 'api_key' => self::API_KEY, + 'public_key' => self::PUBLIC_KEY, + 'serve_enabled' => true, + 'llms_txt_enabled' => true, + ), + $overrides + ) + ); + } + + /** + * Turns the current request into one from an AI crawler. + * + * @param string $path Request path. + * @param string $user_agent Crawler user agent. + * @return string The absolute URL of the faked request. + */ + protected function fake_crawler_request( $path = '/hello-world/', $user_agent = 'Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)' ) { + // go_to() runs the main query, so the conditional tags the eligibility + // check relies on (is_feed(), is_robots(), …) answer truthfully. + $this->go_to( home_url( $path ) ); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['HTTP_USER_AGENT'] = $user_agent; + + return home_url( $path ); + } + + /** + * Spends the whole per-minute outbound lookup budget. + * + * @param int $limit Budget in force for the test. + * @return void + */ + protected function exhaust_lookup_budget( $limit = 120 ) { + set_transient( 'citecue_budget_' . (int) floor( time() / MINUTE_IN_SECONDS ), $limit, 2 * MINUTE_IN_SECONDS ); + } + + /** + * Primes the page cache for a URL without going through the API. + * + * @param string $url Absolute page URL. + * @param string $body Cached body. + * @param string $etag Cached ETag. + * @param string $mode Optimization mode. + * @return void + */ + protected function prime_page_cache( $url, $body, $etag = '"v1"', $mode = 'enriched' ) { + $this->plugin->cache->set_page( $url, $body, $etag, $mode ); + } + + /** + * A proxy bound to the plugin under test. + * + * @return Citecue_Proxy + */ + protected function proxy() { + return new Citecue_Proxy( $this->plugin ); + } + + /** + * An llms.txt handler bound to the plugin under test. + * + * @return Citecue_Llms_Txt + */ + protected function llms_txt() { + return new Citecue_Llms_Txt( $this->plugin ); + } + + /** + * Asserts the proxy left the request to WordPress, for the stated reason. + * + * @param string $reason Expected decision reason. + * @param array $decision Decision under test. + * @return void + */ + protected function assertPassedThrough( $reason, array $decision ) { + $this->assertFalse( $decision['serve'], "Expected pass-through ({$reason}), got a served response." ); + $this->assertSame( $reason, $decision['reason'] ); + } + + /** + * Asserts the proxy served a body. + * + * @param string $body Expected body. + * @param array $decision Decision under test. + * @param bool $stale Whether the body is expected to be flagged stale. + * @return void + */ + protected function assertServed( $body, array $decision, $stale = false ) { + $this->assertTrue( $decision['serve'], "Expected a served response, got pass-through ({$decision['reason']})." ); + $this->assertSame( $body, $decision['body'] ); + if ( isset( $decision['stale'] ) ) { + $this->assertSame( $stale, $decision['stale'] ); + } + } +} diff --git a/tests/includes/class-citecue-woocommerce-stub.php b/tests/includes/class-citecue-woocommerce-stub.php new file mode 100644 index 0000000..8adb6a6 --- /dev/null +++ b/tests/includes/class-citecue-woocommerce-stub.php @@ -0,0 +1,87 @@ + + */ + public static $state = array( + 'cart' => false, + 'checkout' => false, + 'account' => false, + 'endpoint' => false, + ); + + /** + * Whether the stub (rather than a real WooCommerce) is providing the API. + * + * @var bool + */ + private static $active = false; + + /** + * Declares the stub class/functions if WooCommerce itself is absent. + * + * @return void + */ + public static function install() { + if ( class_exists( 'WooCommerce' ) || function_exists( 'is_cart' ) ) { + return; + } + + require_once __DIR__ . '/woocommerce-stub-functions.php'; + + self::$active = true; + } + + /** + * Whether the stub is in charge (i.e. no real WooCommerce is loaded). + * + * @return bool + */ + public static function is_active() { + return self::$active; + } + + /** + * Pretends the current request is a given WooCommerce page. + * + * @param string $page One of cart|checkout|account|endpoint. + * @return void + */ + public static function pretend( $page ) { + self::reset(); + self::$state[ $page ] = true; + } + + /** + * Clears the pretend state. + * + * @return void + */ + public static function reset() { + foreach ( array_keys( self::$state ) as $key ) { + self::$state[ $key ] = false; + } + } +} diff --git a/tests/includes/woocommerce-stub-functions.php b/tests/includes/woocommerce-stub-functions.php new file mode 100644 index 0000000..88569c5 --- /dev/null +++ b/tests/includes/woocommerce-stub-functions.php @@ -0,0 +1,53 @@ +