diff --git a/CHANGELOG.md b/CHANGELOG.md index da3d9d4..3ea6af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added + +- `$breeze_warmup_priority` — order the Breeze warmup list by importance + (per-language homepages, Breeze's own manual list, menu membership, post + type and `` freshness) instead of leaving it in sitemap order. + Weights are declared in `$breeze_warmup_priority_weights` and filterable via + `timberkit_warmup_priority_weights`. Off by default. +- Site Health check `preload_chain_healthy` — reports a Breeze preload chain + that has stopped making progress. + +### Changed + +- The warmup option row now stores the ordered list, the signals behind it, a + weight fingerprint and a revision counter. A row written by an earlier + version reads as stale and is refreshed; no migration is needed. +- `fetchSitemapUrls()` now deduplicates by canonical URL form rather than + exact string, so two spellings of the same page (differing in trailing + slash, scheme case, default port or fragment) collapse to one, first-seen + spelling winning. + ## [1.38.0] - 2026-08-20 ### Added diff --git a/README.md b/README.md index 371f417..9ca3850 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,113 @@ The class discovers the sitemap (AIOSEO's `/sitemap.xml` when active, otherwise core's `/wp-sitemap.xml`), follows a sitemap index recursively within bounded limits, and merges the result in. +`fetchSitemapUrls()` deduplicates by **canonical URL form**, not by exact +string: two spellings of the same page — differing only in trailing slash, +scheme case, default port, or fragment — collapse to one entry, and the +first-seen spelling wins. Warming the same page twice under two spellings +would waste a slot of the URL cap. + +### Ordering the warmup list by importance + +By default the sitemap-sourced URLs are merged in whatever order the sitemap +generator emitted them, and Breeze warms its queue strictly front to back — +so that order decides how long after a purge a page stays cold. +`$breeze_warmup_priority` (default `false`) replaces it with a computed +ordering, scored during the deferred refresh and stored ready to serve, so +the purge-time filter still pays no cost that grows with the sitemap's size. +Requires `$breeze_warmup_sitemap` — on its own it has nothing to order. + +```php +class Base extends StarterBase { + public function __construct() { + $this->breeze_warmup_sitemap = true; + $this->breeze_warmup_priority = true; + + parent::__construct(); + } +} +``` + +Score is a sum of independent signals, so a fresh page in a menu outranks a +menu page nobody has touched in a year: + +| Signal | Default weight | Notes | +| --- | --- | --- | +| `front_page` | 1000 | A language's homepage. | +| `manual` | 800 | Already in Breeze's own preload list. | +| `menu` | 500 | Linked from a registered nav menu. | +| `types` | `[]` | Per-post-type points, keyed by post type slug. Empty by default. | +| `freshness` | `2 => 300, 7 => 200, 30 => 100, 365 => 25` | Points by `` age in days, ascending buckets. Anything older, missing, unparseable, or **in the future** scores 0. | + +Override the map wholesale in `$breeze_warmup_priority_weights`, or adjust it +per project with the `timberkit_warmup_priority_weights` filter (runs once, +at registration — not per purge). + +A future `` scores 0 rather than the top freshness bucket: scheduled +content is not fresh content, and a broken `lastmod` must never be able to +shoot a URL to the front of the queue. + +`types` is keyed by post type slug, for example: + +```php +class Base extends StarterBase { + public function __construct() { + $this->breeze_warmup_priority_weights = array( + 'front_page' => 1000, + 'manual' => 800, + 'menu' => 500, + 'types' => array( 'realizace' => 150 ), + 'freshness' => array( 2 => 300, 7 => 200, 30 => 100, 365 => 25 ), + ); + + parent::__construct(); + } +} +``` + +The post type is derived from the **sub-sitemap filename**, not looked up in +the database: `wp-sitemap-posts--N.xml` for core, +`-sitemap.xml` for AIOSEO. A URL whose sub-sitemap doesn't match either +shape gets no type, so its `types` weight is 0. AIOSEO's structural indexes +(`author`, `date`, `product_attributes`, `rss`, `additional`) are excluded on +purpose — they share the `-sitemap.xml` shape but aren't post types. A +taxonomy sitemap can't be told apart from a post-type one by filename either, +so it falls through to weight 0 as well. If a `types` weight seems to have no +effect, check the sub-sitemap's filename first. + +Three things worth knowing before this ships to a real sitemap: + +**Freshness reads ``, which is the date of the last edit, not of +publication.** A ten-year-old page with a fixed typo therefore ranks as +fresh. The signal is free — it is already in the XML we parse — and +resolving real publication dates would mean matching URLs back to posts +through WPML and custom permalinks. + +**The cap is not the whole cost.** `timberkit_warmup_sitemap_max_urls` +(default 200) applies only to sitemap-sourced URLs. Entries Breeze itself +supplies are warmed on top of it, and every language's homepage and menu +items are guaranteed even when that pushes the total over the cap — the cap +is soft by design. Budget roughly 200 origin renders and about three and a +half minutes of warming, plus Breeze's own entries, plus any guarantee +overflow. + +**Warming a page nobody visits within the cache TTL (24 hours by default) is +wasted work** — the cache expires before the visitor arrives. That 24 hours +is Breeze's own page-cache expiry setting; it is unrelated to this class's +`CACHE_TTL` (one hour), which governs a different clock — how long the +stored URL list itself is trusted before a refresh is scheduled. A practical +way to size the cap: count how many URLs got at least one pageview +yesterday; that number is the cap. + +### Preload chain health + +The Site Health check `preload_chain_healthy` (category `caching`, needs +`$site_health`) watches Breeze's own preload queue for silent stalls. Breeze +drives that queue through an Action Scheduler loopback; when the loopback +can't reach the site, the queue simply stops advancing and nothing anywhere +reports it. The check flags a queue that hasn't made progress in the last +minute. + --- ## Usage diff --git a/docs/adr/0006-warmup-priority-precomputed-at-refresh.md b/docs/adr/0006-warmup-priority-precomputed-at-refresh.md new file mode 100644 index 0000000..40d80f3 --- /dev/null +++ b/docs/adr/0006-warmup-priority-precomputed-at-refresh.md @@ -0,0 +1,64 @@ +# 0006. Precompute warmup priority at refresh, never at purge + +## Context + +Breeze processes its preload queue strictly front to back, at roughly a +second per URL. Position in the array is the schedule: whatever sits at +index 0 is warm within a second of the purge, and whatever sits near the end +may stay cold for minutes. `BreezeWarmupSitemap` already merges thousands of +sitemap-sourced URLs into that queue, and the merge runs inside the +`breeze_preload_urls` filter — which fires **synchronously inside the purge +request**, the same request an editor is sitting in front of after clicking +Save. + +A sitemap can hold thousands of URLs on a real site. Anything this filter +does costs that editor wall-clock time, once per purge, for as long as the +site exists. That rules out any per-purge step whose cost scales with the +sitemap's size — sorting by a computed score chief among them. + +## Decision + +Ordering is computed once, during the existing deferred refresh job, and +stored as a finished list. The purge-time filter only reads that list and +splices it against Breeze's own entries positionally — homepage, then +Breeze's unscored entries, then the stored ordering — a cost independent of +how many URLs the sitemap holds. + +Two alternatives were rejected: + +- **Sort at purge time.** Cheapest to build, but it is exactly the cost this + decision exists to avoid: an O(n log n) sort of a sitemap-sized list, + paid synchronously by a user waiting on the request. +- **Drop menu membership as a signal.** Menu membership is the strongest + cheap signal of importance available — a human deliberately linked that + page from every page of the site — and it costs one option read during the + refresh. Dropping it to simplify the hot path would have thrown away a free + signal to protect a path that was never going to use it anyway. + +## Consequences + +The ordering is only ever as fresh as the last refresh, not as fresh as the +last purge. That forced three separate invalidation paths, because no single +one covers every way the ordering can go stale: a TTL for the ordinary case +of time passing, a weight fingerprint so a config change (a deploy changing +`$breeze_warmup_priority_weights`, or the `timberkit_warmup_priority_weights` +filter) is noticed without polling, and an in-place rescore on +`wp_update_nav_menu` so a menu edit — the single fastest way to invalidate +the strongest signal — doesn't wait for the TTL to catch up. + +Two writers now share one `wp_options` row: the cron refresh and the +menu-change rescore. WordPress options are last-write-wins, so a slow cron +refresh that started before a menu edit and finishes after it could silently +overwrite the newer ordering with a stale one. A revision counter guards the +case that actually happens in practice — the refresh re-reads the revision +immediately before writing and discards its result if it has moved. It does +not guard two writers landing in the same instant; closing that would need a +conditional `UPDATE` matched against the serialized option value, which is +disproportionate to a race between an hourly cron job and a human saving a +menu. + +The cap became soft. Every language's homepage and every menu item are +guaranteed a slot even when that pushes the total past +`timberkit_warmup_sitemap_max_urls` — a language losing its homepage from the +warmup list is a worse outcome than a cap overrun bounded by the number of +menu items. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7336d7d..4fec9e3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -69,3 +69,4 @@ guard (test, CI check, convention) that keeps it from drifting, if any. - [0003](0003-dev-media-origin-env-and-self-host-guard.md) — Dev-media origin via env, with a self-host guard - [0004](0004-image-downscaling-via-core-threshold.md) — Drive downscaling through core's threshold; never delete originals on upload - [0005](0005-first-party-gtm-container.md) — Load the GTM container from the kit, configured in code +- [0006](0006-warmup-priority-precomputed-at-refresh.md) — Precompute warmup priority at refresh, never at purge diff --git a/src/BreezeWarmup/LanguageQuota.php b/src/BreezeWarmup/LanguageQuota.php new file mode 100644 index 0000000..0b5d4e3 --- /dev/null +++ b/src/BreezeWarmup/LanguageQuota.php @@ -0,0 +1,117 @@ +> $records Scored records. + * @param int $max Soft cap on total URLs. + * @return array> Selected records, input order preserved. + */ + public static function apply( array $records, int $max ): array { + $max = max( 0, $max ); + $keep = array(); + $dropped = array(); + + foreach ( $records as $i => $record ) { + if ( ! empty( $record['front_page'] ) || ! empty( $record['menu'] ) ) { + $keep[ $i ] = true; + } else { + $dropped[] = $i; + } + } + + $budget = max( 0, $max - count( $keep ) ); + if ( $budget > 0 && array() !== $dropped ) { + foreach ( self::selectByLanguage( $records, $dropped, $budget ) as $i ) { + $keep[ $i ] = true; + } + } + + $result = array(); + foreach ( $records as $i => $record ) { + if ( isset( $keep[ $i ] ) ) { + $result[] = $record; + } + } + + return $result; + } + + /** + * Split the remaining budget across languages in proportion to how many + * optional URLs each has, then take that many best-scoring ones. + * + * Proportional rather than equal: on a site where 90 percent of the + * content is Czech, an equal split would hand English a third of the + * budget for nothing. + * + * @param array> $records + * @param array $candidates Indexes eligible for selection. + * @param int $budget + * @return array Selected indexes. + */ + private static function selectByLanguage( array $records, array $candidates, int $budget ): array { + $byLang = array(); + foreach ( $candidates as $i ) { + $lang = isset( $records[ $i ]['lang'] ) ? (string) $records[ $i ]['lang'] : ''; + $byLang[ $lang ] = $byLang[ $lang ] ?? array(); + $byLang[ $lang ][] = $i; + } + + $total = count( $candidates ); + $selected = array(); + $assigned = 0; + + // Largest-remainder is overkill here; floor each share and hand any + // leftover slots to the languages with the most candidates, so no + // slot is wasted to rounding. + $quotas = array(); + foreach ( $byLang as $lang => $indexes ) { + $quotas[ $lang ] = (int) floor( $budget * count( $indexes ) / $total ); + $assigned += $quotas[ $lang ]; + } + + $leftover = $budget - $assigned; + if ( $leftover > 0 ) { + uasort( $byLang, static fn( array $a, array $b ): int => count( $b ) <=> count( $a ) ); + foreach ( array_keys( $byLang ) as $lang ) { + if ( $leftover <= 0 ) { + break; + } + ++$quotas[ $lang ]; + --$leftover; + } + } + + foreach ( $byLang as $lang => $indexes ) { + // Guard the read: a record that never went through the Scorer has no + // 'score' key, and it should sort last rather than warn. + usort( + $indexes, + static fn( int $a, int $b ): int => ( (int) ( $records[ $b ]['score'] ?? 0 ) ) <=> ( (int) ( $records[ $a ]['score'] ?? 0 ) ) + ); + foreach ( array_slice( $indexes, 0, $quotas[ $lang ] ) as $i ) { + $selected[] = $i; + } + } + + return $selected; + } +} diff --git a/src/BreezeWarmup/PriorityStore.php b/src/BreezeWarmup/PriorityStore.php new file mode 100644 index 0000000..d435d8d --- /dev/null +++ b/src/BreezeWarmup/PriorityStore.php @@ -0,0 +1,134 @@ +, signals: array, fetched_at: int, weights_hash: string, revision: int}|null + */ + public static function read(): ?array { + if ( ! function_exists( 'get_option' ) ) { + return null; + } + + $data = get_option( self::OPTION_KEY, null ); + + if ( + ! is_array( $data ) + || ! isset( $data['urls'], $data['signals'], $data['fetched_at'], $data['weights_hash'], $data['revision'] ) + || ! is_array( $data['urls'] ) + || ! is_array( $data['signals'] ) + ) { + return null; + } + + return array( + 'urls' => array_values( array_filter( $data['urls'], 'is_string' ) ), + 'signals' => $data['signals'], + 'fetched_at' => (int) $data['fetched_at'], + 'weights_hash' => (string) $data['weights_hash'], + 'revision' => (int) $data['revision'], + ); + } + + /** + * Tolerant URL-only read, accepting either the current five-key payload + * or the legacy `{urls, fetched_at}` shape a pre-upgrade site left behind. + * + * {@see self::read()} is deliberately strict: a legacy row must read as + * null there so the caller schedules a refresh (that IS the migration — + * there is no other conversion step). But the purge-time filter still + * needs *some* URL list to hand Breeze in the window between an upgrade + * and the first cron refresh — this is the URL source for BOTH branches + * of that filter (ordering off and ordering on), not only the legacy + * one; `read()` is used there solely to decide staleness and whether the + * weights changed. Falling back to nothing here would be a regression: + * today's code at least keeps serving the stale list. + * + * @return array + */ + public static function readUrls(): array { + if ( ! function_exists( 'get_option' ) ) { + return array(); + } + + $data = get_option( self::OPTION_KEY, null ); + if ( ! is_array( $data ) || ! isset( $data['fetched_at'] ) || ! is_array( $data['urls'] ?? null ) ) { + return array(); + } + + return array_values( array_filter( $data['urls'], 'is_string' ) ); + } + + /** + * Current revision, 0 when the row is missing or legacy. + * + * @return int + */ + public static function revision(): int { + $data = self::read(); + + return null === $data ? 0 : $data['revision']; + } + + /** + * @param array $urls Ordered URLs. + * @param array $signals Canonical key => signal record. + * @param string $weightsHash + * @param int $expectedRevision Revision the caller read before computing. + * @return bool True when written, false when a concurrent write won. + */ + public static function write( array $urls, array $signals, string $weightsHash, int $expectedRevision ): bool { + if ( ! function_exists( 'update_option' ) ) { + return false; + } + + if ( self::revision() !== $expectedRevision ) { + return false; + } + + update_option( + self::OPTION_KEY, + array( + 'urls' => array_values( $urls ), + 'signals' => $signals, + 'fetched_at' => function_exists( 'time' ) ? time() : 0, + 'weights_hash' => $weightsHash, + 'revision' => $expectedRevision + 1, + ), + false + ); + + return true; + } +} diff --git a/src/BreezeWarmup/Scorer.php b/src/BreezeWarmup/Scorer.php new file mode 100644 index 0000000..716d14e --- /dev/null +++ b/src/BreezeWarmup/Scorer.php @@ -0,0 +1,176 @@ +, freshness: array} + */ + public const DEFAULT_WEIGHTS = array( + 'front_page' => 1000, + 'manual' => 800, + 'menu' => 500, + 'types' => array(), + 'freshness' => array( + 2 => 300, + 7 => 200, + 30 => 100, + 365 => 25, + ), + ); + + /** + * Bucketed freshness. Bucketed rather than continuous because the buckets + * are trivially testable and immune to clock skew of a few minutes. + * + * The boundary belongs to the higher bucket (strict `<`). A missing, + * unparseable or **future** timestamp scores zero: scheduled content is + * not fresh content, and a broken `lastmod` must never be able to shoot a + * URL to the front of the queue. + * + * @param int|null $lastmod Unix timestamp, or null. + * @param int $now Unix timestamp to measure against. + * @param array $buckets Days => points, ascending by days. + * @return int + */ + public static function freshness( ?int $lastmod, int $now, array $buckets ): int { + if ( null === $lastmod || $lastmod > $now ) { + return 0; + } + + $ageDays = ( $now - $lastmod ) / self::DAY; + + ksort( $buckets ); + foreach ( $buckets as $days => $points ) { + if ( $ageDays < $days ) { + return (int) $points; + } + } + + return 0; + } + + /** + * $now is required, not defaulted: a default lets a caller who passes a + * real lastmod but forgets $now silently lose the whole freshness + * contribution, with no warning and no exception to catch it. + * + * @param array $record + * @param array $weights + * @param int $now + * @return int + */ + public static function score( array $record, array $weights, int $now ): int { + $score = 0; + + if ( ! empty( $record['front_page'] ) ) { + $score += (int) ( $weights['front_page'] ?? 0 ); + } + if ( ! empty( $record['manual'] ) ) { + $score += (int) ( $weights['manual'] ?? 0 ); + } + if ( ! empty( $record['menu'] ) ) { + $score += (int) ( $weights['menu'] ?? 0 ); + } + + $type = isset( $record['type'] ) ? (string) $record['type'] : ''; + if ( '' !== $type ) { + $types = is_array( $weights['types'] ?? null ) ? $weights['types'] : array(); + $score += (int) ( $types[ $type ] ?? 0 ); + } + + $buckets = is_array( $weights['freshness'] ?? null ) ? $weights['freshness'] : array(); + $lastmod = isset( $record['lastmod'] ) ? (int) $record['lastmod'] : null; + $score += self::freshness( $lastmod, $now, $buckets ); + + return $score; + } + + /** + * @param array> $records + * @param array $weights + * @param int $now + * @return array> Records with a `score` key added. + */ + public static function scoreAll( array $records, array $weights, int $now ): array { + foreach ( $records as $i => $record ) { + $records[ $i ]['score'] = self::score( $record, $weights, $now ); + } + + return array_values( $records ); + } + + /** + * Stable descending sort by score. + * + * PHP's `usort` has been stable since 8.0, but the tie-break is stated + * explicitly here because the whole design leans on it: ties preserve the + * sitemap's own ordering, which for AIOSEO is newest-first — free date + * ordering as a last resort. + * + * @param array> $records + * @return array> + */ + public static function sort( array $records ): array { + $records = array_values( $records ); + + usort( + $records, + static fn( array $a, array $b ): int => ( (int) ( $b['score'] ?? 0 ) ) <=> ( (int) ( $a['score'] ?? 0 ) ) + ); + + return $records; + } + + /** + * Fingerprint of the effective weight map. + * + * Stored alongside the ordered list so a deploy that changes the weights + * invalidates the ordering by itself. Cheaper than tracking *when* the + * config changed: record *what it was* and let the mismatch notice. + * + * Key order must not affect the result, or a harmless reordering in + * `StarterBase` would trigger a needless refresh. + * + * @param array $weights + * @return string + */ + public static function weightsHash( array $weights ): string { + $normalized = self::sortKeysDeep( $weights ); + + return md5( (string) json_encode( $normalized ) ); + } + + /** + * @param array $value + * @return array + */ + private static function sortKeysDeep( array $value ): array { + ksort( $value ); + foreach ( $value as $k => $v ) { + if ( is_array( $v ) ) { + $value[ $k ] = self::sortKeysDeep( $v ); + } + } + + return $value; + } +} diff --git a/src/BreezeWarmup/SignalCollector.php b/src/BreezeWarmup/SignalCollector.php new file mode 100644 index 0000000..f802792 --- /dev/null +++ b/src/BreezeWarmup/SignalCollector.php @@ -0,0 +1,189 @@ + + */ + public static function menuKeys(): array { + if ( ! function_exists( 'wp_get_nav_menus' ) || ! function_exists( 'wp_get_nav_menu_items' ) ) { + return array(); + } + + $keys = array(); + + foreach ( (array) wp_get_nav_menus() as $menu ) { + $id = is_object( $menu ) && isset( $menu->term_id ) ? (int) $menu->term_id : 0; + $items = 0 !== $id ? wp_get_nav_menu_items( $id ) : false; + if ( ! is_array( $items ) ) { + continue; + } + + foreach ( $items as $item ) { + $url = is_object( $item ) && isset( $item->url ) ? trim( (string) $item->url ) : ''; + if ( '' === $url ) { + continue; + } + + // A menu can hold custom links that are pure fragments + // ('#section') or non-page targets ('mailto:'). Only a real + // http(s) page URL can ever join with a sitemap record, so + // anything else must not become a junk key in the map. + $scheme = strtolower( (string) ( parse_url( $url, PHP_URL_SCHEME ) ?: '' ) ); + $host = (string) ( parse_url( $url, PHP_URL_HOST ) ?: '' ); + if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || '' === $host ) { + continue; + } + + $keys[ UrlCanonicalizer::canonicalize( $url ) ] = true; + } + } + + return $keys; + } + + /** + * Homepage of every active language, keyed by canonical URL. + * + * Breeze only ever knows about one homepage — whichever language the + * purge request happened to run in. Every other translation lands + * wherever the sitemap put it, which is the gap this closes. + * + * Foreign hosts are dropped without complaint: under WPML's + * domain-per-language mode each domain warms itself in its own purge + * request, so one language remaining here is correct, not a failure. + * + * @return array Canonical URL => language code ('' without WPML). + */ + public static function frontPages(): array { + if ( ! function_exists( 'home_url' ) ) { + return array(); + } + + $home = (string) home_url( '/' ); + $host = strtolower( (string) ( parse_url( $home, PHP_URL_HOST ) ?: '' ) ); + + $languages = function_exists( 'apply_filters' ) + ? apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => false ) ) + : null; + + if ( ! is_array( $languages ) || array() === $languages ) { + return array( UrlCanonicalizer::canonicalize( $home ) => '' ); + } + + $pages = array(); + foreach ( $languages as $language ) { + $url = is_array( $language ) && isset( $language['url'] ) ? (string) $language['url'] : ''; + if ( '' === $url ) { + continue; + } + + if ( strtolower( (string) ( parse_url( $url, PHP_URL_HOST ) ?: '' ) ) !== $host ) { + continue; + } + + $code = is_array( $language ) && isset( $language['language_code'] ) + ? (string) $language['language_code'] + : ''; + + $pages[ UrlCanonicalizer::canonicalize( $url ) ] = $code; + } + + return array() === $pages ? array( UrlCanonicalizer::canonicalize( $home ) => '' ) : $pages; + } + + /** + * Canonical URLs the admin typed into Breeze's Preload settings tab. + * + * Read here rather than from the filter argument: at purge time there is + * no room to score anything, and in the refresh job this is one cheap + * option read. + * + * @return array + */ + public static function manualKeys(): array { + if ( ! function_exists( 'breeze_get_option' ) ) { + return array(); + } + + $options = breeze_get_option( 'preload_settings', false ); + $raw = is_array( $options ) && isset( $options['breeze-preload-cache-urls'] ) + ? $options['breeze-preload-cache-urls'] + : array(); + + if ( ! is_array( $raw ) ) { + return array(); + } + + $keys = array(); + foreach ( $raw as $url ) { + if ( ! is_string( $url ) || '' === trim( $url ) ) { + continue; + } + $keys[ UrlCanonicalizer::canonicalize( trim( $url ) ) ] = true; + } + + return $keys; + } + + /** + * Active language codes plus the default, for language attribution. + * + * @return array{codes: array, default: string} + */ + public static function activeLanguages(): array { + $languages = function_exists( 'apply_filters' ) + ? apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => false ) ) + : null; + + if ( ! is_array( $languages ) || array() === $languages ) { + return array( 'codes' => array(), 'default' => '' ); + } + + $codes = array(); + $default = ''; + foreach ( $languages as $language ) { + $code = is_array( $language ) && isset( $language['language_code'] ) + ? strtolower( (string) $language['language_code'] ) + : ''; + if ( '' === $code ) { + continue; + } + $codes[] = $code; + if ( '' === $default ) { + $default = $code; + } + } + + $currentDefault = function_exists( 'apply_filters' ) + ? apply_filters( 'wpml_default_language', null ) + : null; + + if ( is_string( $currentDefault ) && '' !== $currentDefault ) { + $default = strtolower( $currentDefault ); + } + + return array( 'codes' => $codes, 'default' => $default ); + } +} diff --git a/src/BreezeWarmup/SourceNaming.php b/src/BreezeWarmup/SourceNaming.php new file mode 100644 index 0000000..cfac259 --- /dev/null +++ b/src/BreezeWarmup/SourceNaming.php @@ -0,0 +1,115 @@ +-sitemap.xml` shape + * with a post-type sitemap but are not post types at all. A taxonomy + * sitemap cannot be told apart from a post-type one by filename alone; + * that case is not solved here and simply falls through to weight 0, + * the safe default the rest of the design already relies on. + * + * @var string[] + */ + private const AIOSEO_NON_POST_TYPE_INDEXES = array( 'author', 'date', 'product_attributes', 'rss', 'additional' ); + + /** + * Post type from a sub-sitemap URL. + * + * Core emits `wp-sitemap-posts--.xml`; AIOSEO emits + * `-sitemap.xml`. The result is not validated against + * `get_post_types()` — it is only a key into the weight map, and an + * unknown key scores 0 exactly like an unrecognised name would. + * + * @param string $sitemapUrl + * @return string Post type, or '' when the name is not recognised. + */ + public static function derivePostType( string $sitemapUrl ): string { + $path = (string) ( parse_url( $sitemapUrl, PHP_URL_PATH ) ?: '' ); + $base = basename( $path ); + if ( '' === $base ) { + return ''; + } + + $base = preg_replace( '/\.gz$/i', '', $base ) ?? $base; + + if ( 1 === preg_match( '/^wp-sitemap-posts-(.+)-\d+\.xml$/i', $base, $m ) ) { + return strtolower( $m[1] ); + } + + if ( 1 === preg_match( '/^(.+)-sitemap(?:\d+)?\.xml$/i', $base, $m ) ) { + // "wp-sitemap.xml" itself matches nothing useful; guard the known + // index names so a root document is not read as a type. + $candidate = strtolower( $m[1] ); + + if ( in_array( $candidate, array( 'wp', '' ), true ) ) { + return ''; + } + + return in_array( $candidate, self::AIOSEO_NON_POST_TYPE_INDEXES, true ) ? '' : $candidate; + } + + return ''; + } + + /** + * Language for a URL, in falling order of confidence. + * + * @param string $url The page URL. + * @param string $sitemapUrl The sub-sitemap it came from. + * @param array $activeCodes Active language codes. + * @param string $defaultCode Site default language code. + * @return string + */ + public static function deriveLanguage( string $url, string $sitemapUrl, array $activeCodes, string $defaultCode ): string { + $codes = array_map( 'strtolower', $activeCodes ); + + $fromSitemap = self::firstPathSegment( $sitemapUrl ); + if ( '' !== $fromSitemap && in_array( $fromSitemap, $codes, true ) ) { + return $fromSitemap; + } + + $fromPath = self::firstPathSegment( $url ); + if ( '' !== $fromPath && in_array( $fromPath, $codes, true ) ) { + return $fromPath; + } + + $query = (string) ( parse_url( $url, PHP_URL_QUERY ) ?: '' ); + if ( '' !== $query ) { + parse_str( $query, $params ); + $lang = isset( $params['lang'] ) && is_string( $params['lang'] ) ? strtolower( $params['lang'] ) : ''; + if ( '' !== $lang && in_array( $lang, $codes, true ) ) { + return $lang; + } + } + + return strtolower( $defaultCode ); + } + + /** + * @param string $url + * @return string Lowercased first path segment, or '' when there is none. + */ + private static function firstPathSegment( string $url ): string { + $path = (string) ( parse_url( $url, PHP_URL_PATH ) ?: '' ); + $segments = array_values( array_filter( explode( '/', $path ), static fn( string $s ): bool => '' !== $s ) ); + + return isset( $segments[0] ) ? strtolower( $segments[0] ) : ''; + } +} diff --git a/src/BreezeWarmup/UrlCanonicalizer.php b/src/BreezeWarmup/UrlCanonicalizer.php new file mode 100644 index 0000000..60a17ea --- /dev/null +++ b/src/BreezeWarmup/UrlCanonicalizer.php @@ -0,0 +1,73 @@ + Default port per scheme. */ + private const DEFAULT_PORTS = array( + 'http' => 80, + 'https' => 443, + ); + + /** + * @param string $url Absolute http(s) URL. + * @return string Canonical form, or the input unchanged when it cannot be parsed. + */ + public static function canonicalize( string $url ): string { + $parts = parse_url( $url ); + if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { + return $url; + } + + $scheme = strtolower( (string) $parts['scheme'] ); + $host = strtolower( (string) $parts['host'] ); + + $port = ''; + if ( isset( $parts['port'] ) && ( self::DEFAULT_PORTS[ $scheme ] ?? null ) !== (int) $parts['port'] ) { + $port = ':' . (int) $parts['port']; + } + + $path = isset( $parts['path'] ) ? (string) $parts['path'] : ''; + $path = self::withTrailingSlash( $path ); + $query = isset( $parts['query'] ) && '' !== $parts['query'] ? '?' . $parts['query'] : ''; + + return $scheme . '://' . $host . $port . $path . $query; + } + + /** + * Add a trailing slash unless the last segment looks like a file — a dot + * in the final segment is the cheapest available signal for that, and + * getting it wrong only costs one duplicate entry, never a wrong page. + * + * @param string $path + * @return string + */ + private static function withTrailingSlash( string $path ): string { + if ( '' === $path ) { + return '/'; + } + + if ( str_ends_with( $path, '/' ) ) { + return $path; + } + + $last = substr( $path, (int) strrpos( $path, '/' ) + 1 ); + + return str_contains( $last, '.' ) ? $path : $path . '/'; + } +} diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index ddd4dd7..bf14b0e 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -4,6 +4,13 @@ namespace Parisek\TimberKit; +use Parisek\TimberKit\BreezeWarmup\LanguageQuota; +use Parisek\TimberKit\BreezeWarmup\PriorityStore; +use Parisek\TimberKit\BreezeWarmup\Scorer; +use Parisek\TimberKit\BreezeWarmup\SignalCollector; +use Parisek\TimberKit\BreezeWarmup\SourceNaming; +use Parisek\TimberKit\BreezeWarmup\UrlCanonicalizer; + /** * Feeds Breeze's Cache Warmup preloader with every URL from the site's XML * sitemap, via the `breeze_preload_urls` filter. @@ -54,8 +61,14 @@ final class BreezeWarmupSitemap { /** @var bool Prevent duplicate hook registration. */ private static bool $registered = false; - /** @var string wp_options key holding the last-known-good URL list + fetch timestamp (autoload off). */ - private const STORAGE_OPTION_KEY = 'timber_kit_breeze_warmup_sitemap_urls'; + /** @var bool Whether ordering is enabled for this project. */ + private static bool $priority_enabled = false; + + /** @var string Fingerprint of the effective weights, computed once at registration. */ + private static string $weights_hash = ''; + + /** @var array|null Effective weight map for this project, set at registration. */ + private static ?array $weights = null; /** @var string Transient key for the short refresh lock. */ private const LOCK_KEY = 'timber_kit_breeze_warmup_sitemap_refresh_lock'; @@ -97,9 +110,10 @@ final class BreezeWarmupSitemap { * so the class stays self-guarding when used directly, e.g. from tests or * a project that wires it without going through `StarterBase`. * + * @param array|null $weights * @return void */ - public static function register(): void { + public static function register( bool $priority = false, ?array $weights = null ): void { if ( self::$registered ) { return; } @@ -108,10 +122,94 @@ public static function register(): void { return; } - self::$registered = true; + self::$registered = true; + self::$priority_enabled = $priority; + self::$weights = $weights ?? Scorer::DEFAULT_WEIGHTS; add_filter( 'breeze_preload_urls', array( self::class, 'filterPreloadUrls' ) ); add_action( self::CRON_HOOK, array( self::class, 'runRefresh' ) ); + + if ( $priority ) { + // Computed once here, never per purge — the hot path may only + // afford a string comparison against the stored hash. Must be + // built from the FILTERED weights (self::weights()), not the + // raw self::$weights: the hash a write stores (runRefresh(), + // rescoreOnMenuUpdate()) is built the same way, and the two must + // agree — otherwise a project using the + // `timberkit_warmup_priority_weights` filter would see + // weightsChanged() report a mismatch on every single purge, + // scheduling a needless refresh forever. + self::$weights_hash = Scorer::weightsHash( self::weights() ); + + // Priority 5: Breeze's own menu purge and the kit's both sit at + // 10, so the rescore must land before them — the purge they + // trigger then reads an ordering that already reflects the new + // menu. + add_action( 'wp_update_nav_menu', array( self::class, 'rescoreOnMenuUpdate' ), 5 ); + } + } + + /** + * Recompute the ordering from stored signals after a menu changed. + * + * No network: menu membership is the only signal that changed, and + * everything else is already stored. With no stored signals this does + * nothing but schedule a refresh — writing a partial list would be worse + * than leaving the stale one in place. + * + * @return void + */ + public static function rescoreOnMenuUpdate(): void { + if ( ! self::isEnabled() || ! self::$priority_enabled ) { + return; + } + + try { + $stored = PriorityStore::read(); + if ( null === $stored || array() === $stored['signals'] ) { + self::maybeScheduleRefresh(); + + return; + } + + $menu = SignalCollector::menuKeys(); + $weights = self::weights(); + $records = array(); + + foreach ( $stored['signals'] as $key => $signal ) { + if ( ! is_array( $signal ) || ! isset( $signal['url'] ) ) { + continue; + } + + $records[] = array( + 'url' => (string) $signal['url'], + 'key' => (string) $key, + 'lastmod' => isset( $signal['lastmod'] ) ? $signal['lastmod'] : null, + 'type' => (string) ( $signal['type'] ?? '' ), + 'lang' => (string) ( $signal['lang'] ?? '' ), + 'menu' => isset( $menu[ (string) $key ] ), + 'front_page' => (bool) ( $signal['front_page'] ?? false ), + 'manual' => (bool) ( $signal['manual'] ?? false ), + ); + } + + if ( array() === $records ) { + return; + } + + $built = self::buildOrderedUrls( $records, $weights, time(), self::maxUrls() ); + + PriorityStore::write( + $built['urls'], + $built['signals'], + Scorer::weightsHash( $weights ), + $stored['revision'] + ); + } catch ( \Throwable $e ) { + // Best-effort by contract: this runs synchronously inside the + // editor's Save request, and a failure here must never surface + // as a fatal in that request. + } } /** @@ -143,75 +241,48 @@ public static function filterPreloadUrls( mixed $urls ): array { return $existing; } - $stored = self::getStoredData(); - if ( null === $stored || self::isStale( $stored ) ) { + $stored = PriorityStore::read(); + if ( null === $stored || self::isStale( $stored ) || self::weightsChanged( $stored ) ) { self::maybeScheduleRefresh(); } - $sitemap_urls = null !== $stored ? $stored['urls'] : array(); + if ( ! self::$priority_enabled ) { + return self::legacyMerge( $existing, self::getStoredUrls() ); + } - return self::mergeUrls( $existing, $sitemap_urls ); + return self::mergeUrls( $existing, self::getStoredUrls(), function_exists( 'home_url' ) ? (string) home_url( '/' ) : '' ); } /** - * Current last-known-good sitemap URL list, for inspection/testing. - * Never triggers a fetch or a refresh. + * Whether the stored ordering was built with a different weight map. * - * @return array - */ - public static function getStoredUrls(): array { - $stored = self::getStoredData(); - - return null !== $stored ? $stored['urls'] : array(); - } - - /** - * Read the stored `{urls, fetched_at}` payload, tolerating a missing or - * malformed option value. + * The hash is computed once at registration and compared here, so the + * purge path pays one string comparison — not a filter call and not a + * hash. Recording *what the config was* is cheaper than tracking *when it + * changed*. * - * @return array{urls: array, fetched_at: int}|null + * @param array{weights_hash: string} $stored + * @return bool */ - private static function getStoredData(): ?array { - if ( ! function_exists( 'get_option' ) ) { - return null; - } - - $data = get_option( self::STORAGE_OPTION_KEY, null ); - if ( ! is_array( $data ) || ! isset( $data['urls'], $data['fetched_at'] ) || ! is_array( $data['urls'] ) ) { - return null; - } - - return array( - 'urls' => array_values( array_filter( $data['urls'], 'is_string' ) ), - 'fetched_at' => (int) $data['fetched_at'], - ); + private static function weightsChanged( array $stored ): bool { + return self::$priority_enabled && '' !== self::$weights_hash && $stored['weights_hash'] !== self::$weights_hash; } /** - * Persist a freshly fetched URL list as the new last known good, stamped - * with the current time. Caller ({@see self::runRefresh()}) is - * responsible for never calling this with an empty list. + * Current last-known-good sitemap URL list, for inspection/testing. + * Never triggers a fetch or a refresh. * - * @param array $urls - * @return void + * Tolerant of the legacy `{urls, fetched_at}` payload as well as the + * current one — see {@see PriorityStore::readUrls()} for why. + * + * @return array */ - private static function storeData( array $urls ): void { - if ( ! function_exists( 'update_option' ) ) { - return; - } - - update_option( - self::STORAGE_OPTION_KEY, - array( - 'urls' => array_values( $urls ), - 'fetched_at' => function_exists( 'time' ) ? time() : 0, - ), - false - ); + public static function getStoredUrls(): array { + return PriorityStore::readUrls(); } /** - * @param array{urls: array, fetched_at: int} $data + * @param array{urls: array, signals: array, fetched_at: int, weights_hash: string, revision: int} $data * @return bool */ private static function isStale( array $data ): bool { @@ -275,48 +346,199 @@ private static function releaseRefreshLock(): void { } /** - * Deferred-refresh cron callback ({@see self::CRON_HOOK}) — does the - * actual sitemap crawl and, only on a non-empty result, replaces the - * stored last known good list. An empty or failed crawl leaves whatever - * was previously stored untouched, so a transient sitemap outage never - * wipes out a previously working warmup list. + * Deferred-refresh cron callback. A failed or empty crawl never overwrites + * the last known good list — stale data always beats no data. + * + * The body wraps in try/finally because it now does far more than fetch + * and store: any throw between here and the release used to hold the lock + * until its TTL expired, which silently blocked retries for a minute. * * @return void */ public static function runRefresh(): void { - $urls = self::fetchSitemapUrls(); + try { + $revision = PriorityStore::revision(); + $records = self::fetchSitemapRecords(); + + if ( array() === $records ) { + return; + } + + $records = self::enrichRecords( $records ); + $weights = self::weights(); + $built = self::buildOrderedUrls( $records, $weights, time(), self::maxUrls() ); + + PriorityStore::write( $built['urls'], $built['signals'], Scorer::weightsHash( $weights ), $revision ); + } catch ( \Throwable $e ) { + // Best-effort by contract: a sitemap outage must never surface as + // a fatal in a cron job. + } finally { + self::releaseRefreshLock(); + } + } - if ( array() !== $urls ) { - self::storeData( $urls ); + /** + * Score, budget and order a set of sitemap records. + * + * Split out from {@see self::runRefresh()} so the ordering rules can be + * tested without mocking the network: everything here is deterministic + * given its arguments. + * + * @param array> $records + * @param array $weights + * @param int $now + * @param int $max + * @return array{urls: array, signals: array} + */ + public static function buildOrderedUrls( array $records, array $weights, int $now, int $max ): array { + $scored = Scorer::scoreAll( $records, $weights, $now ); + $kept = LanguageQuota::apply( $scored, $max ); + $ordered = Scorer::sort( $kept ); + + $signals = array(); + foreach ( $ordered as $record ) { + $signals[ (string) $record['key'] ] = array( + 'lastmod' => $record['lastmod'], + 'type' => (string) $record['type'], + 'lang' => (string) $record['lang'], + 'menu' => (bool) $record['menu'], + 'front_page' => (bool) $record['front_page'], + 'manual' => (bool) $record['manual'], + 'url' => (string) $record['url'], + ); } - self::releaseRefreshLock(); + return array( + 'urls' => array_column( $ordered, 'url' ), + 'signals' => $signals, + ); } /** - * Fetch and parse the site's sitemap into a flat, deduped, same-host URL - * list. Never throws — any failure along the way degrades to an empty - * array. Only ever called from the deferred refresh job, never from the - * purge-time filter callback. + * Attach the signals a sitemap cannot carry, and resolve each record's + * language. * - * @return array + * @param array> $records + * @return array> */ - public static function fetchSitemapUrls(): array { + private static function enrichRecords( array $records ): array { + $menu = SignalCollector::menuKeys(); + $frontPages = SignalCollector::frontPages(); + $manual = SignalCollector::manualKeys(); + $languages = SignalCollector::activeLanguages(); + + foreach ( $records as $i => $record ) { + $key = (string) $record['key']; + + $records[ $i ]['menu'] = isset( $menu[ $key ] ); + $records[ $i ]['front_page'] = isset( $frontPages[ $key ] ); + $records[ $i ]['manual'] = isset( $manual[ $key ] ); + $records[ $i ]['lang'] = isset( $frontPages[ $key ] ) && '' !== $frontPages[ $key ] + ? $frontPages[ $key ] + : SourceNaming::deriveLanguage( + (string) $record['url'], + (string) ( $record['source'] ?? '' ), + $languages['codes'], + $languages['default'] + ); + } + + return $records; + } + + /** + * Effective weight map: the defaults, filterable per project. + * + * The `timberkit_warmup_priority_weights` filter must be a pure function + * of its input: its result is fingerprinted and that fingerprint is + * compared across requests to decide whether the stored ordering is + * stale. A callback that varies between requests (reading mutable state + * such as an option that changes) will schedule a refresh on every purge. + * + * @return array + */ + public static function weights(): array { + $weights = self::$weights ?? Scorer::DEFAULT_WEIGHTS; + + $filtered = function_exists( 'apply_filters' ) + ? apply_filters( 'timberkit_warmup_priority_weights', $weights ) + : $weights; + + return is_array( $filtered ) ? $filtered : $weights; + } + + /** + * @return int + */ + private static function maxUrls(): int { + $max = function_exists( 'apply_filters' ) + ? apply_filters( 'timberkit_warmup_sitemap_max_urls', self::DEFAULT_MAX_URLS ) + : self::DEFAULT_MAX_URLS; + + return is_numeric( $max ) ? max( 0, (int) $max ) : self::DEFAULT_MAX_URLS; + } + + /** + * Structured sitemap crawl. Never throws — any failure degrades to an + * empty array. Only ever called from the deferred refresh job. + * + * @return array> + */ + public static function fetchSitemapRecords(): array { try { $root = self::resolveSitemapRootUrl(); if ( '' === $root ) { return array(); } - $seen = array(); - $urls = self::fetchAndParseSitemap( $root, 0, $seen ); + $seen = array(); + $records = self::fetchAndParseSitemap( $root, 0, $seen ); - return array_values( array_unique( $urls ) ); + return self::dedupeByKey( $records ); } catch ( \Throwable $e ) { return array(); } } + /** + * Backwards-compatible string view of {@see self::fetchSitemapRecords()}. + * + * Entries are deduplicated by canonical URL form, not by exact string — + * two spellings of the same page (differing only in trailing slash, + * scheme case, default port, or fragment) collapse to one, and the + * first-seen spelling wins. Warming the same page twice wastes a slot of + * the URL cap, and the canonical key is what joins this list with the + * signals coming from menus and Breeze's own preload list. + * + * @return array + */ + public static function fetchSitemapUrls(): array { + return array_column( self::fetchSitemapRecords(), 'url' ); + } + + /** + * First-seen-wins: when two records share a canonical key, later ones + * are dropped rather than overwriting the first. + * + * @param array> $records + * @return array> + */ + private static function dedupeByKey( array $records ): array { + $seen = array(); + $result = array(); + + foreach ( $records as $record ) { + $key = (string) $record['key']; + if ( isset( $seen[ $key ] ) ) { + continue; + } + $seen[ $key ] = true; + $result[] = $record; + } + + return $result; + } + /** * AIOSEO-first sitemap root URL resolution, falling back to WordPress core. * @@ -352,7 +574,7 @@ private static function isAioseoActive(): bool { * @param string $url Sitemap (or sub-sitemap) URL. * @param int $depth Current recursion depth. * @param array $seen URLs already fetched, by reference — guards against index cycles. - * @return array URLs collected from `` entries. + * @return array> Records collected from `` entries. */ private static function fetchAndParseSitemap( string $url, int $depth, array &$seen ): array { if ( ! self::isFetchableSameHostUrl( $url ) ) { @@ -386,7 +608,7 @@ private static function fetchAndParseSitemap( string $url, int $depth, array &$s } if ( 'urlset' === $root_name ) { - return self::collectFromUrlset( $xml ); + return self::collectFromUrlset( $xml, $url ); } return array(); @@ -398,15 +620,15 @@ private static function fetchAndParseSitemap( string $url, int $depth, array &$s * @param \SimpleXMLElement $xml Parsed `` root. * @param int $depth Current recursion depth. * @param array $seen URLs already fetched, by reference. - * @return array + * @return array> */ private static function collectFromIndex( \SimpleXMLElement $xml, int $depth, array &$seen ): array { if ( $depth >= self::MAX_DEPTH ) { return array(); } - $urls = array(); - $count = 0; + $records = array(); + $count = 0; foreach ( $xml->sitemap as $sitemap ) { if ( $count >= self::MAX_SUBSITEMAPS ) { @@ -423,21 +645,23 @@ private static function collectFromIndex( \SimpleXMLElement $xml, int $depth, ar // same-host http(s) URL before fetching it — this counts a // rejected off-host entry against MAX_SUBSITEMAPS too, which is // fine: it's still one entry consumed either way. - $urls = array( ...$urls, ...self::fetchAndParseSitemap( $loc, $depth + 1, $seen ) ); + $records = array( ...$records, ...self::fetchAndParseSitemap( $loc, $depth + 1, $seen ) ); } - return $urls; + return $records; } /** * Collect `` entries from a `` document, keeping only * same-host URLs. * - * @param \SimpleXMLElement $xml Parsed `` root. - * @return array + * @param \SimpleXMLElement $xml Parsed `` root. + * @param string $sourceUrl The document this urlset came from. + * @return array> */ - private static function collectFromUrlset( \SimpleXMLElement $xml ): array { - $urls = array(); + private static function collectFromUrlset( \SimpleXMLElement $xml, string $sourceUrl ): array { + $type = SourceNaming::derivePostType( $sourceUrl ); + $records = array(); foreach ( $xml->url as $entry ) { $loc = isset( $entry->loc ) ? trim( (string) $entry->loc ) : ''; @@ -445,10 +669,37 @@ private static function collectFromUrlset( \SimpleXMLElement $xml ): array { continue; } - $urls[] = $loc; + $records[] = array( + 'url' => $loc, + 'key' => UrlCanonicalizer::canonicalize( $loc ), + 'lastmod' => self::parseLastmod( isset( $entry->lastmod ) ? trim( (string) $entry->lastmod ) : '' ), + 'type' => $type, + 'source' => $sourceUrl, + 'lang' => '', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ); + } + + return $records; + } + + /** + * `` to a unix timestamp. Anything unparseable is null rather + * than "now" — a broken timestamp must not read as fresh content. + * + * @param string $raw + * @return int|null + */ + private static function parseLastmod( string $raw ): ?int { + if ( '' === $raw ) { + return null; } - return $urls; + $ts = strtotime( $raw ); + + return false === $ts ? null : $ts; } /** @@ -595,7 +846,7 @@ private static function isFetchableSameHostUrl( string $url ): bool { * @param array $sitemap_urls Same-host URLs collected from the sitemap. * @return array */ - private static function mergeUrls( array $existing, array $sitemap_urls ): array { + private static function legacyMerge( array $existing, array $sitemap_urls ): array { $max = apply_filters( 'timberkit_warmup_sitemap_max_urls', self::DEFAULT_MAX_URLS ); $max = is_numeric( $max ) ? max( 0, (int) $max ) : self::DEFAULT_MAX_URLS; @@ -619,12 +870,100 @@ private static function mergeUrls( array $existing, array $sitemap_urls ): array return $merged; } + /** + * Positional merge of Breeze's own list with our ordered one. + * + * Sorting is not allowed here — this runs synchronously inside the purge + * request, so the cost must not grow with the size of the sitemap. The + * rule is therefore positional: + * + * homepage, then Breeze entries we cannot score, then our ordering. + * + * Entries Breeze supplied that *are* in the sitemap already carry the + * `manual` weight and sorted themselves; the ones that are not have no + * signals at all, so they go right behind the homepage, matching + * `manual` being the second highest weight. + * + * Membership is tested on canonical keys. Breeze builds the homepage with + * `trailingslashit()` while a sitemap may emit it bare — on raw strings + * those are two URLs and the homepage would be warmed twice. + * + * @param array $existing Breeze's own preload URL list. + * @param array $ordered Our stored, already ordered list. + * @param string $homeUrl Current language homepage. + * @return array + */ + public static function mergeUrls( array $existing, array $ordered, string $homeUrl ): array { + $homeKey = UrlCanonicalizer::canonicalize( $homeUrl ); + + // This runs synchronously inside the purge request, and $ordered can + // hold as many URLs as the store's cap allows — up to 1000 on one + // site in this fleet. Canonicalize each distinct input URL exactly + // once here and read the memoized key everywhere below, instead of + // re-canonicalizing on every membership check and dedup lookup. + $existingKeyed = array(); + foreach ( $existing as $url ) { + $existingKeyed[] = array( $url, UrlCanonicalizer::canonicalize( $url ) ); + } + + $orderedKeyed = array(); + $orderedMap = array(); + foreach ( $ordered as $url ) { + $key = UrlCanonicalizer::canonicalize( $url ); + $orderedKeyed[] = array( $url, $key ); + $orderedMap[ $key ] = true; + } + + // When a URL appears in both lists, Breeze's own spelling wins — the + // sitemap only supplies ordering, and Breeze must warm exactly what + // it was already going to warm. + $existingByKey = array(); + foreach ( $existingKeyed as [ $url, $key ] ) { + if ( ! isset( $existingByKey[ $key ] ) ) { + $existingByKey[ $key ] = $url; + } + } + + $result = array(); + $seen = array(); + + $push = static function ( string $url, string $key ) use ( &$result, &$seen ): void { + if ( isset( $seen[ $key ] ) ) { + return; + } + $seen[ $key ] = true; + $result[] = $url; + }; + + foreach ( $existingKeyed as [ $url, $key ] ) { + if ( $key === $homeKey ) { + $push( $url, $key ); + break; + } + } + + foreach ( $existingKeyed as [ $url, $key ] ) { + if ( ! isset( $orderedMap[ $key ] ) ) { + $push( $url, $key ); + } + } + + foreach ( $orderedKeyed as [ $url, $key ] ) { + $push( $existingByKey[ $key ] ?? $url, $key ); + } + + return $result; + } + /** * Reset internal state so tests can re-register the module. * * @return void */ public static function reset_for_tests(): void { - self::$registered = false; + self::$registered = false; + self::$priority_enabled = false; + self::$weights_hash = ''; + self::$weights = null; } } diff --git a/src/Health/Check/PreloadChainHealthy.php b/src/Health/Check/PreloadChainHealthy.php new file mode 100644 index 0000000..fbd0948 --- /dev/null +++ b/src/Health/Check/PreloadChainHealthy.php @@ -0,0 +1,90 @@ +` — which is the date of the last *edit*, not of + * publication. + * + * @var array + */ + protected array $breeze_warmup_priority_weights = array( + 'front_page' => 1000, + 'manual' => 800, + 'menu' => 500, + 'types' => array(), + 'freshness' => array( 2 => 300, 7 => 200, 30 => 100, 365 => 25 ), + ); + /** * ACF Datastore ({@see https://www.advancedcustomfields.com/resources/acf-settings-enable_datastore/}). * @@ -1199,6 +1233,7 @@ protected function default_health_checks(): array { new RestUsersRestricted(), new Utf8mb4Tables(), new GtmContainerNotDuplicated( array() !== $this->gtm_containers && GtmContainer::enabled() ), + new PreloadChainHealthy(), ); } @@ -1272,7 +1307,10 @@ protected function setup_breeze_warmup_sitemap(): void { return; } - BreezeWarmupSitemap::register(); + BreezeWarmupSitemap::register( + $this->breeze_warmup_priority, + $this->breeze_warmup_priority_weights + ); } /** diff --git a/tests/Property/BreezeWarmup/ScorerSortTest.php b/tests/Property/BreezeWarmup/ScorerSortTest.php new file mode 100644 index 0000000..ffee5bd --- /dev/null +++ b/tests/Property/BreezeWarmup/ScorerSortTest.php @@ -0,0 +1,63 @@ +forAll( Generator\seq( Generator\nat() ) ) + ->then( function ( array $scores ): void { + $records = array(); + foreach ( array_values( $scores ) as $i => $score ) { + $records[] = array( + 'url' => 'https://example.test/' . $i . '/', + 'score' => $score, + ); + } + + $sorted = Scorer::sort( $records ); + + $this->assertCount( count( $records ), $sorted ); + + $in = array_column( $records, 'url' ); + $out = array_column( $sorted, 'url' ); + sort( $in ); + sort( $out ); + $this->assertSame( $in, $out ); + } ); + } + + public function test_sorting_never_increases_score_going_down_the_list(): void { + $this->forAll( Generator\seq( Generator\nat() ) ) + ->then( function ( array $scores ): void { + $records = array(); + foreach ( array_values( $scores ) as $i => $score ) { + $records[] = array( + 'url' => 'https://example.test/' . $i . '/', + 'score' => $score, + ); + } + + $sorted = array_column( Scorer::sort( $records ), 'score' ); + + for ( $i = 1, $n = count( $sorted ); $i < $n; $i++ ) { + $this->assertLessThanOrEqual( $sorted[ $i - 1 ], $sorted[ $i ] ); + } + } ); + } +} diff --git a/tests/Unit/BreezeWarmup/LanguageQuotaTest.php b/tests/Unit/BreezeWarmup/LanguageQuotaTest.php new file mode 100644 index 0000000..3580431 --- /dev/null +++ b/tests/Unit/BreezeWarmup/LanguageQuotaTest.php @@ -0,0 +1,184 @@ + $overrides + * @return array + */ + private function record( string $url, array $overrides = array() ): array { + return array_merge( + array( + 'url' => $url, + 'key' => $url, + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + 'score' => 0, + ), + $overrides + ); + } + + public function test_single_language_is_a_plain_cap(): void { + $records = array(); + for ( $i = 0; $i < 10; $i++ ) { + $records[] = $this->record( 'https://example.test/' . $i . '/', array( 'score' => 100 - $i ) ); + } + + $result = LanguageQuota::apply( $records, 4 ); + + $this->assertCount( 4, $result ); + $this->assertSame( 'https://example.test/0/', $result[0]['url'] ); + } + + public function test_budget_is_split_proportionally_to_url_count(): void { + // 8 Czech URLs, 2 Slovak, cap 5 -> Czech gets 4, Slovak gets 1. + $records = array(); + for ( $i = 0; $i < 8; $i++ ) { + $records[] = $this->record( 'https://example.test/cs' . $i . '/', array( 'lang' => 'cs', 'score' => 10 ) ); + } + for ( $i = 0; $i < 2; $i++ ) { + $records[] = $this->record( 'https://example.test/sk' . $i . '/', array( 'lang' => 'sk', 'score' => 10 ) ); + } + + $result = LanguageQuota::apply( $records, 5 ); + $byLang = array_count_values( array_column( $result, 'lang' ) ); + + $this->assertSame( 4, $byLang['cs'] ); + $this->assertSame( 1, $byLang['sk'] ); + } + + public function test_front_page_and_menu_are_always_kept(): void { + $records = array( + $this->record( 'https://example.test/', array( 'lang' => 'cs', 'front_page' => true, 'score' => 1000 ) ), + $this->record( 'https://example.test/sk/', array( 'lang' => 'sk', 'front_page' => true, 'score' => 1000 ) ), + $this->record( 'https://example.test/kontakt/', array( 'lang' => 'cs', 'menu' => true, 'score' => 500 ) ), + $this->record( 'https://example.test/filler/', array( 'lang' => 'cs', 'score' => 1 ) ), + ); + + $result = LanguageQuota::apply( $records, 1 ); + $urls = array_column( $result, 'url' ); + + $this->assertContains( 'https://example.test/', $urls ); + $this->assertContains( 'https://example.test/sk/', $urls ); + $this->assertContains( 'https://example.test/kontakt/', $urls ); + $this->assertNotContains( 'https://example.test/filler/', $urls ); + } + + public function test_guarantees_may_overflow_the_cap(): void { + // Cap 2, but four guaranteed records. Guarantees win; the cap is soft. + $records = array(); + foreach ( array( 'cs', 'sk', 'en', 'de' ) as $lang ) { + $records[] = $this->record( + 'https://example.test/' . $lang . '/', + array( 'lang' => $lang, 'front_page' => true, 'score' => 1000 ) + ); + } + + $result = LanguageQuota::apply( $records, 2 ); + + $this->assertCount( 4, $result ); + } + + public function test_zero_cap_still_keeps_guarantees(): void { + $records = array( + $this->record( 'https://example.test/', array( 'front_page' => true, 'score' => 1000 ) ), + $this->record( 'https://example.test/x/', array( 'score' => 1 ) ), + ); + + $result = LanguageQuota::apply( $records, 0 ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'https://example.test/', $result[0]['url'] ); + } + + public function test_preserves_input_order(): void { + // apply() selects; it must not reorder. Sorting happens after. + $records = array( + $this->record( 'https://example.test/a/', array( 'score' => 5 ) ), + $this->record( 'https://example.test/b/', array( 'score' => 9 ) ), + ); + + $result = LanguageQuota::apply( $records, 2 ); + + $this->assertSame( + array( 'https://example.test/a/', 'https://example.test/b/' ), + array_column( $result, 'url' ) + ); + } + + public function test_leftover_slots_go_to_the_language_with_most_candidates(): void { + // cs=3, sk=2, en=2, cap 4 -> floors are 1/1/1 (3 assigned, 1 leftover). + // The leftover slot must go to cs, the language with the most candidates. + $records = array(); + for ( $i = 0; $i < 3; $i++ ) { + $records[] = $this->record( 'https://example.test/cs' . $i . '/', array( 'lang' => 'cs', 'score' => 10 ) ); + } + for ( $i = 0; $i < 2; $i++ ) { + $records[] = $this->record( 'https://example.test/sk' . $i . '/', array( 'lang' => 'sk', 'score' => 10 ) ); + } + for ( $i = 0; $i < 2; $i++ ) { + $records[] = $this->record( 'https://example.test/en' . $i . '/', array( 'lang' => 'en', 'score' => 10 ) ); + } + + $result = LanguageQuota::apply( $records, 4 ); + $byLang = array_count_values( array_column( $result, 'lang' ) ); + + $this->assertCount( 4, $result ); + $this->assertSame( 2, $byLang['cs'] ); + $this->assertSame( 1, $byLang['sk'] ); + $this->assertSame( 1, $byLang['en'] ); + } + + public function test_cap_exceeding_candidate_count_returns_all_of_them(): void { + $records = array( + $this->record( 'https://example.test/a/', array( 'score' => 3 ) ), + $this->record( 'https://example.test/b/', array( 'score' => 2 ) ), + $this->record( 'https://example.test/c/', array( 'score' => 1 ) ), + ); + + $result = LanguageQuota::apply( $records, 100 ); + + $this->assertCount( 3, $result ); + } + + public function test_missing_score_key_does_not_warn(): void { + // A record that never went through the Scorer must not raise an + // "Undefined array key" warning; it should simply sort last. + $records = array( + array( + 'url' => 'https://example.test/no-score/', + 'key' => 'https://example.test/no-score/', + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ), + $this->record( 'https://example.test/scored/', array( 'score' => 5 ) ), + ); + + $result = LanguageQuota::apply( $records, 2 ); + + $this->assertCount( 2, $result ); + } +} diff --git a/tests/Unit/BreezeWarmup/PriorityStoreTest.php b/tests/Unit/BreezeWarmup/PriorityStoreTest.php new file mode 100644 index 0000000..8d98bae --- /dev/null +++ b/tests/Unit/BreezeWarmup/PriorityStoreTest.php @@ -0,0 +1,177 @@ +justReturn( + array( + 'urls' => array( 'https://example.test/' ), + 'signals' => array( 'https://example.test/' => array( 'menu' => true ) ), + 'fetched_at' => 123, + 'weights_hash' => 'abc', + 'revision' => 4, + ) + ); + + $data = PriorityStore::read(); + + $this->assertNotNull( $data ); + $this->assertSame( 4, $data['revision'] ); + $this->assertSame( 'abc', $data['weights_hash'] ); + } + + public function test_legacy_payload_without_signals_reads_as_null(): void { + // A v1.x row has only urls + fetched_at. Treating it as null makes the + // caller schedule a refresh, which is exactly the desired migration: + // none. + Functions\when( 'get_option' )->justReturn( + array( 'urls' => array( 'https://example.test/' ), 'fetched_at' => 123 ) + ); + + $this->assertNull( PriorityStore::read() ); + } + + public function test_missing_option_reads_as_null(): void { + Functions\when( 'get_option' )->justReturn( null ); + + $this->assertNull( PriorityStore::read() ); + } + + public function test_write_succeeds_when_the_revision_is_unchanged(): void { + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array(), + 'signals' => array(), + 'fetched_at' => 1, + 'weights_hash' => 'a', + 'revision' => 2, + ) + ); + $written = null; + Functions\when( 'update_option' )->alias( + static function ( string $key, $value ) use ( &$written ): bool { + $written = $value; + + return true; + } + ); + + $ok = PriorityStore::write( array( 'https://example.test/' ), array(), 'b', 2 ); + + $this->assertTrue( $ok ); + $this->assertSame( 3, $written['revision'], 'the stored revision advances' ); + } + + public function test_write_is_discarded_when_the_revision_moved(): void { + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array(), + 'signals' => array(), + 'fetched_at' => 1, + 'weights_hash' => 'a', + 'revision' => 9, + ) + ); + Functions\expect( 'update_option' )->never(); + + $this->assertFalse( PriorityStore::write( array( 'x' ), array(), 'b', 2 ) ); + } + + public function test_write_on_an_empty_store_succeeds_from_revision_zero(): void { + Functions\when( 'get_option' )->justReturn( null ); + Functions\when( 'update_option' )->justReturn( true ); + + $this->assertTrue( PriorityStore::write( array( 'https://example.test/' ), array(), 'b', 0 ) ); + } + + public function test_read_urls_reads_the_current_shape(): void { + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/' ), + 'signals' => array(), + 'fetched_at' => 123, + 'weights_hash' => 'abc', + 'revision' => 4, + ) + ); + + $this->assertSame( array( 'https://example.test/' ), PriorityStore::readUrls() ); + } + + public function test_read_urls_reads_the_legacy_shape(): void { + // A v1.x row has only urls + fetched_at. read() treats this as null + // (correctly — it is not a scored payload), but readUrls() must still + // hand back the URLs: an upgraded site must keep sending Breeze *some* + // list in the window before the first cron refresh writes the new + // shape, rather than going empty. + Functions\when( 'get_option' )->justReturn( + array( 'urls' => array( 'https://example.test/a/' ), 'fetched_at' => 123 ) + ); + + $this->assertSame( array( 'https://example.test/a/' ), PriorityStore::readUrls() ); + } + + public function test_read_urls_returns_empty_array_when_option_missing(): void { + Functions\when( 'get_option' )->justReturn( null ); + + $this->assertSame( array(), PriorityStore::readUrls() ); + } + + public function test_read_urls_returns_empty_array_when_option_is_not_an_array(): void { + Functions\when( 'get_option' )->justReturn( 'not-an-array' ); + + $this->assertSame( array(), PriorityStore::readUrls() ); + } + + public function test_read_urls_returns_empty_array_when_fetched_at_is_missing(): void { + Functions\when( 'get_option' )->justReturn( array( 'urls' => array( 'https://example.test/a/' ) ) ); + + $this->assertSame( array(), PriorityStore::readUrls() ); + } + + public function test_read_urls_returns_empty_array_when_urls_key_is_not_an_array(): void { + Functions\when( 'get_option' )->justReturn( array( 'urls' => 'oops', 'fetched_at' => 123 ) ); + + $this->assertSame( array(), PriorityStore::readUrls() ); + } + + public function test_read_urls_filters_out_non_string_url_entries(): void { + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/a/', 42, null, 'https://example.test/b/' ), + 'fetched_at' => 123, + ) + ); + + $this->assertSame( + array( 'https://example.test/a/', 'https://example.test/b/' ), + PriorityStore::readUrls() + ); + } +} diff --git a/tests/Unit/BreezeWarmup/ScorerTest.php b/tests/Unit/BreezeWarmup/ScorerTest.php new file mode 100644 index 0000000..70ba130 --- /dev/null +++ b/tests/Unit/BreezeWarmup/ScorerTest.php @@ -0,0 +1,182 @@ + $overrides + * @return array + */ + private function record( array $overrides = array() ): array { + return array_merge( + array( + 'url' => 'https://example.test/a/', + 'key' => 'https://example.test/a/', + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ), + $overrides + ); + } + + // -- freshness buckets -------------------------------------------------- + + /** + * @return array + */ + public static function freshnessCases(): array { + $day = 86400; + + return array( + 'today' => array( 0, 300 ), + 'just under two days' => array( 2 * $day - 1, 300 ), + 'exactly two days' => array( 2 * $day, 200 ), + 'just under seven days' => array( 7 * $day - 1, 200 ), + 'exactly seven days' => array( 7 * $day, 100 ), + 'exactly thirty days' => array( 30 * $day, 25 ), + 'exactly a year' => array( 365 * $day, 0 ), + 'ancient' => array( 4000 * $day, 0 ), + ); + } + + #[DataProvider('freshnessCases')] + public function test_freshness_buckets( int $ageSeconds, int $expected ): void { + $this->assertSame( + $expected, + Scorer::freshness( self::NOW - $ageSeconds, self::NOW, Scorer::DEFAULT_WEIGHTS['freshness'] ) + ); + } + + public function test_missing_lastmod_scores_zero(): void { + $this->assertSame( 0, Scorer::freshness( null, self::NOW, Scorer::DEFAULT_WEIGHTS['freshness'] ) ); + } + + public function test_future_lastmod_scores_zero(): void { + // Scheduled content is not fresh content, and a broken lastmod must + // never be able to shoot a URL to the front of the queue. + $this->assertSame( + 0, + Scorer::freshness( self::NOW + 86400, self::NOW, Scorer::DEFAULT_WEIGHTS['freshness'] ) + ); + } + + // -- score composition -------------------------------------------------- + + public function test_front_page_weight(): void { + $score = Scorer::score( $this->record( array( 'front_page' => true ) ), Scorer::DEFAULT_WEIGHTS, self::NOW ); + + $this->assertSame( 1000, $score ); + } + + public function test_manual_weight(): void { + $this->assertSame( 800, Scorer::score( $this->record( array( 'manual' => true ) ), Scorer::DEFAULT_WEIGHTS, self::NOW ) ); + } + + public function test_menu_weight(): void { + $this->assertSame( 500, Scorer::score( $this->record( array( 'menu' => true ) ), Scorer::DEFAULT_WEIGHTS, self::NOW ) ); + } + + public function test_type_weight_comes_from_the_map(): void { + $weights = Scorer::DEFAULT_WEIGHTS; + $weights['types']['realizace'] = 50; + + $this->assertSame( 50, Scorer::score( $this->record( array( 'type' => 'realizace' ) ), $weights, self::NOW ) ); + } + + public function test_unknown_type_scores_zero(): void { + $this->assertSame( 0, Scorer::score( $this->record( array( 'type' => 'nonesuch' ) ), Scorer::DEFAULT_WEIGHTS, self::NOW ) ); + } + + public function test_record_without_a_lastmod_key_scores_without_a_warning(): void { + $this->assertSame( + 1000, + Scorer::score( array( 'front_page' => true ), Scorer::DEFAULT_WEIGHTS, self::NOW ) + ); + } + + public function test_components_add_up(): void { + // A fresh page in a menu must outrank a stale page in a menu — that is + // why the model sums instead of taking a maximum. + $record = $this->record( + array( + 'menu' => true, + 'lastmod' => self::NOW, + ) + ); + + $this->assertSame( 800, Scorer::scoreAll( array( $record ), Scorer::DEFAULT_WEIGHTS, self::NOW )[0]['score'] ); + } + + // -- sorting ------------------------------------------------------------ + + public function test_sorts_descending_by_score(): void { + $records = array( + array_merge( $this->record( array( 'url' => 'https://example.test/low/' ) ), array( 'score' => 10 ) ), + array_merge( $this->record( array( 'url' => 'https://example.test/high/' ) ), array( 'score' => 900 ) ), + ); + + $sorted = Scorer::sort( $records ); + + $this->assertSame( 'https://example.test/high/', $sorted[0]['url'] ); + } + + public function test_ties_keep_input_order(): void { + // Stable ordering preserves the sitemap's own date ordering as a free + // last resort. AIOSEO emits newest-first inside each sub-sitemap. + $records = array(); + foreach ( array( 'a', 'b', 'c', 'd' ) as $slug ) { + $records[] = array_merge( + $this->record( array( 'url' => 'https://example.test/' . $slug . '/' ) ), + array( 'score' => 100 ) + ); + } + + $sorted = array_column( Scorer::sort( $records ), 'url' ); + + $this->assertSame( + array( + 'https://example.test/a/', + 'https://example.test/b/', + 'https://example.test/c/', + 'https://example.test/d/', + ), + $sorted + ); + } + + // -- weights hash ------------------------------------------------------- + + public function test_weights_hash_is_stable_across_key_order(): void { + $a = array( 'menu' => 500, 'manual' => 800 ); + $b = array( 'manual' => 800, 'menu' => 500 ); + + $this->assertSame( Scorer::weightsHash( $a ), Scorer::weightsHash( $b ) ); + } + + public function test_weights_hash_changes_with_a_value(): void { + $a = array( 'menu' => 500 ); + $b = array( 'menu' => 501 ); + + $this->assertNotSame( Scorer::weightsHash( $a ), Scorer::weightsHash( $b ) ); + } +} diff --git a/tests/Unit/BreezeWarmup/SignalCollectorTest.php b/tests/Unit/BreezeWarmup/SignalCollectorTest.php new file mode 100644 index 0000000..1d94d93 --- /dev/null +++ b/tests/Unit/BreezeWarmup/SignalCollectorTest.php @@ -0,0 +1,261 @@ +alias( + static fn( string $path = '' ): string => 'https://example.test' . $path + ); + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + public function test_menu_keys_are_canonical(): void { + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 7 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->justReturn( + array( + (object) array( 'url' => 'https://example.test/kontakt' ), + (object) array( 'url' => 'https://example.test/o-nas/#tym' ), + ) + ); + + $keys = SignalCollector::menuKeys(); + + $this->assertArrayHasKey( 'https://example.test/kontakt/', $keys ); + $this->assertArrayHasKey( 'https://example.test/o-nas/', $keys ); + } + + public function test_menu_items_without_a_url_are_skipped(): void { + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 7 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->justReturn( + array( + (object) array( 'url' => '' ), + (object) array( 'url' => '#' ), + ) + ); + + $this->assertSame( array(), SignalCollector::menuKeys() ); + } + + public function test_no_menus_yields_no_keys(): void { + Functions\when( 'wp_get_nav_menus' )->justReturn( array() ); + + $this->assertSame( array(), SignalCollector::menuKeys() ); + } + + public function test_menu_items_that_are_not_absolute_http_urls_are_skipped(): void { + // A menu can hold custom links that are pure fragments or mailto: + // targets. Only a real page URL can ever join with a sitemap record, + // so anything else must not become a junk key in the signal map. + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 7 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->justReturn( + array( + (object) array( 'url' => '#section' ), + (object) array( 'url' => 'mailto:a@b.test' ), + ) + ); + + $this->assertSame( array(), SignalCollector::menuKeys() ); + } + + public function test_front_pages_without_wpml_is_just_home(): void { + Functions\when( 'apply_filters' )->alias( + static fn( string $hook, $value ) => $value + ); + + $pages = SignalCollector::frontPages(); + + $this->assertSame( array( 'https://example.test/' => '' ), $pages ); + } + + public function test_front_pages_covers_every_active_language(): void { + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs', 'url' => 'https://example.test/' ), + 'sk' => array( 'language_code' => 'sk', 'url' => 'https://example.test/sk/' ), + ); + } + + return $value; + } + ); + + $pages = SignalCollector::frontPages(); + + $this->assertSame( + array( + 'https://example.test/' => 'cs', + 'https://example.test/sk/' => 'sk', + ), + $pages + ); + } + + public function test_front_pages_drops_foreign_hosts(): void { + // WPML domain-per-language mode. Breeze would reject these in + // preload_url() anyway, so dropping them here is the honest result, + // not an error worth logging. + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs', 'url' => 'https://example.test/' ), + 'sk' => array( 'language_code' => 'sk', 'url' => 'https://example.sk/' ), + ); + } + + return $value; + } + ); + + $this->assertSame( array( 'https://example.test/' => 'cs' ), SignalCollector::frontPages() ); + } + + public function test_front_pages_falls_back_to_home_when_every_language_is_foreign(): void { + // Distinct from "no WPML at all": WPML answered with a non-empty + // array, but domain-per-language mode put every entry on a foreign + // host. The result must still be the single-home fallback, not an + // empty map — an empty preload list would warm nothing at all. + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'sk' => array( 'language_code' => 'sk', 'url' => 'https://example.sk/' ), + 'de' => array( 'language_code' => 'de', 'url' => 'https://example.de/' ), + ); + } + + return $value; + } + ); + + $this->assertSame( array( 'https://example.test/' => '' ), SignalCollector::frontPages() ); + } + + public function test_active_languages_without_wpml_is_empty(): void { + Functions\when( 'apply_filters' )->alias( + static fn( string $hook, $value ) => $value + ); + + $this->assertSame( + array( 'codes' => array(), 'default' => '' ), + SignalCollector::activeLanguages() + ); + } + + public function test_active_languages_lists_codes_and_seeds_default_from_first(): void { + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs' ), + 'sk' => array( 'language_code' => 'sk' ), + ); + } + + return $value; + } + ); + + $this->assertSame( + array( 'codes' => array( 'cs', 'sk' ), 'default' => 'cs' ), + SignalCollector::activeLanguages() + ); + } + + public function test_active_languages_prefers_wpml_default_language_filter(): void { + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs' ), + 'sk' => array( 'language_code' => 'sk' ), + ); + } + + if ( 'wpml_default_language' === $hook ) { + return 'SK'; + } + + return $value; + } + ); + + $this->assertSame( + array( 'codes' => array( 'cs', 'sk' ), 'default' => 'sk' ), + SignalCollector::activeLanguages() + ); + } + + public function test_active_languages_ignores_unusable_default_language_filter_result(): void { + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs' ), + 'sk' => array( 'language_code' => 'sk' ), + ); + } + + if ( 'wpml_default_language' === $hook ) { + return ''; + } + + return $value; + } + ); + + $this->assertSame( + array( 'codes' => array( 'cs', 'sk' ), 'default' => 'cs' ), + SignalCollector::activeLanguages() + ); + } + + // breeze_get_option() is mocked here via Functions\when(), which — unlike + // Functions\expect() — patches the function definition for the rest of + // the process. StarterBase::setupBreezeWarmupSitemap() branches on + // function_exists('breeze_get_option') to detect Breeze's absence, so + // leaking this mock would falsely make Breeze look installed in later, + // unrelated tests. Run in a separate process to keep the leak contained. + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] + public function test_manual_keys_come_from_breeze_settings(): void { + Functions\when( 'breeze_get_option' )->justReturn( + array( 'breeze-preload-cache-urls' => array( 'https://example.test/akce' ) ) + ); + + $this->assertArrayHasKey( 'https://example.test/akce/', SignalCollector::manualKeys() ); + } + + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] + public function test_manual_keys_tolerate_missing_settings(): void { + Functions\when( 'breeze_get_option' )->justReturn( false ); + + $this->assertSame( array(), SignalCollector::manualKeys() ); + } +} diff --git a/tests/Unit/BreezeWarmup/SourceNamingTest.php b/tests/Unit/BreezeWarmup/SourceNamingTest.php new file mode 100644 index 0000000..3807caf --- /dev/null +++ b/tests/Unit/BreezeWarmup/SourceNamingTest.php @@ -0,0 +1,119 @@ + + */ + public static function postTypeCases(): array { + return array( + 'core shape' => array( 'https://example.test/wp-sitemap-posts-post-1.xml', 'post' ), + 'core custom type' => array( 'https://example.test/wp-sitemap-posts-realizace-2.xml', 'realizace' ), + 'core taxonomy is not a post type' => array( 'https://example.test/wp-sitemap-taxonomies-category-1.xml', '' ), + 'aioseo shape' => array( 'https://example.test/post-sitemap.xml', 'post' ), + 'aioseo custom type' => array( 'https://example.test/realizace-sitemap.xml', 'realizace' ), + 'aioseo gzipped' => array( 'https://example.test/post-sitemap.xml.gz', 'post' ), + 'root sitemap' => array( 'https://example.test/sitemap.xml', '' ), + 'core root' => array( 'https://example.test/wp-sitemap.xml', '' ), + 'unknown shape' => array( 'https://example.test/whatever.xml', '' ), + 'empty' => array( '', '' ), + 'aioseo author index is not a post type' => array( 'https://example.test/author-sitemap.xml', '' ), + 'aioseo date index is not a post type' => array( 'https://example.test/date-sitemap.xml', '' ), + 'aioseo product_attributes index is not a post type' => array( 'https://example.test/product_attributes-sitemap.xml', '' ), + ); + } + + #[DataProvider('postTypeCases')] + public function test_derives_post_type( string $sitemapUrl, string $expected ): void { + $this->assertSame( $expected, SourceNaming::derivePostType( $sitemapUrl ) ); + } + + public function test_language_comes_from_the_sub_sitemap_when_it_names_one(): void { + $this->assertSame( + 'sk', + SourceNaming::deriveLanguage( + 'https://example.test/nieco/', + 'https://example.test/sk/post-sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } + + public function test_language_comes_from_the_first_path_segment(): void { + $this->assertSame( + 'sk', + SourceNaming::deriveLanguage( + 'https://example.test/sk/nieco/', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } + + public function test_language_comes_from_the_lang_query_parameter(): void { + $this->assertSame( + 'sk', + SourceNaming::deriveLanguage( + 'https://example.test/?lang=sk', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } + + public function test_language_falls_back_to_the_default(): void { + $this->assertSame( + 'cs', + SourceNaming::deriveLanguage( + 'https://example.test/neco/', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } + + public function test_language_default_is_lowercased(): void { + $this->assertSame( + 'cs', + SourceNaming::deriveLanguage( + 'https://example.test/neco/', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'CS' + ) + ); + } + + public function test_a_path_segment_that_is_not_an_active_code_is_not_a_language(): void { + // "blog" looks like a prefix but is not a registered language, so it + // must not be mistaken for one. + $this->assertSame( + 'cs', + SourceNaming::deriveLanguage( + 'https://example.test/blog/neco/', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } +} diff --git a/tests/Unit/BreezeWarmup/UrlCanonicalizerTest.php b/tests/Unit/BreezeWarmup/UrlCanonicalizerTest.php new file mode 100644 index 0000000..bfd3162 --- /dev/null +++ b/tests/Unit/BreezeWarmup/UrlCanonicalizerTest.php @@ -0,0 +1,79 @@ + + */ + public static function canonicalCases(): array { + return array( + 'lowercases scheme and host' => array( + 'HTTPS://Example.TEST/Page/', + 'https://example.test/Page/', + ), + 'drops the fragment' => array( + 'https://example.test/page/#section', + 'https://example.test/page/', + ), + 'drops the default https port' => array( + 'https://example.test:443/page/', + 'https://example.test/page/', + ), + 'drops the default http port' => array( + 'http://example.test:80/page/', + 'http://example.test/page/', + ), + 'keeps a non-default port' => array( + 'https://example.test:8443/page/', + 'https://example.test:8443/page/', + ), + 'adds the trailing slash' => array( + 'https://example.test/page', + 'https://example.test/page/', + ), + 'leaves a file-looking path alone' => array( + 'https://example.test/feed.xml', + 'https://example.test/feed.xml', + ), + 'root gets a slash' => array( + 'https://example.test', + 'https://example.test/', + ), + 'preserves the query verbatim' => array( + 'https://example.test/?lang=sk&b=1', + 'https://example.test/?lang=sk&b=1', + ), + 'garbage passes through untouched' => array( + 'not a url', + 'not a url', + ), + ); + } + + #[DataProvider('canonicalCases')] + public function test_canonicalizes( string $input, string $expected ): void { + $this->assertSame( $expected, UrlCanonicalizer::canonicalize( $input ) ); + } + + public function test_is_idempotent(): void { + $once = UrlCanonicalizer::canonicalize( 'HTTPS://Example.TEST:443/a#x' ); + $twice = UrlCanonicalizer::canonicalize( $once ); + + $this->assertSame( $once, $twice ); + } +} diff --git a/tests/Unit/BreezeWarmupSitemap/FetchSitemapRecordsTest.php b/tests/Unit/BreezeWarmupSitemap/FetchSitemapRecordsTest.php new file mode 100644 index 0000000..2dd1bd1 --- /dev/null +++ b/tests/Unit/BreezeWarmupSitemap/FetchSitemapRecordsTest.php @@ -0,0 +1,130 @@ +alias( + static fn( string $path = '' ): string => 'https://example.test' . $path + ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + } + + protected function tearDown(): void { + BreezeWarmupSitemap::reset_for_tests(); + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * @param array $bodies URL => body + */ + private function serve( array $bodies ): void { + Functions\when( 'wp_remote_get' )->alias( + static fn( string $url ): array => array( 'body' => $bodies[ $url ] ?? '' ) + ); + Functions\when( 'wp_remote_retrieve_body' )->alias( + static fn( array $r ): string => (string) $r['body'] + ); + } + + public function test_reads_lastmod_into_a_timestamp(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlsetWithLastmod( + array( 'https://example.test/a/' => '2026-08-01T10:00:00+00:00' ) + ), + ) + ); + + $records = BreezeWarmupSitemap::fetchSitemapRecords(); + + $this->assertCount( 1, $records ); + $this->assertSame( strtotime( '2026-08-01T10:00:00+00:00' ), $records[0]['lastmod'] ); + } + + public function test_missing_lastmod_is_null(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlset( array( 'https://example.test/a/' ) ), + ) + ); + + $this->assertNull( BreezeWarmupSitemap::fetchSitemapRecords()[0]['lastmod'] ); + } + + public function test_unparseable_lastmod_is_null(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlsetWithLastmod( + array( 'https://example.test/a/' => 'not a date' ) + ), + ) + ); + + $this->assertNull( BreezeWarmupSitemap::fetchSitemapRecords()[0]['lastmod'] ); + } + + public function test_post_type_comes_from_the_sub_sitemap_name(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::sitemapIndex( + array( 'https://example.test/wp-sitemap-posts-realizace-1.xml' ) + ), + 'https://example.test/wp-sitemap-posts-realizace-1.xml' => Fixtures::urlset( + array( 'https://example.test/realizace/a/' ) + ), + ) + ); + + $this->assertSame( 'realizace', BreezeWarmupSitemap::fetchSitemapRecords()[0]['type'] ); + } + + public function test_record_carries_a_canonical_key(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlset( array( 'https://example.test/a' ) ), + ) + ); + + $record = BreezeWarmupSitemap::fetchSitemapRecords()[0]; + + $this->assertSame( 'https://example.test/a', $record['url'], 'the original URL is what Breeze must warm' ); + $this->assertSame( 'https://example.test/a/', $record['key'] ); + } + + public function test_legacy_string_api_still_works(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlset( + array( 'https://example.test/a/', 'https://example.test/b/' ) + ), + ) + ); + + $this->assertSame( + array( 'https://example.test/a/', 'https://example.test/b/' ), + BreezeWarmupSitemap::fetchSitemapUrls() + ); + } +} diff --git a/tests/Unit/BreezeWarmupSitemap/Fixtures.php b/tests/Unit/BreezeWarmupSitemap/Fixtures.php index 7582006..ee3dd9a 100644 --- a/tests/Unit/BreezeWarmupSitemap/Fixtures.php +++ b/tests/Unit/BreezeWarmupSitemap/Fixtures.php @@ -41,6 +41,26 @@ public static function sitemapIndex( array $locs ): string { . $entries . ''; } + /** + * A `` document whose entries carry a ``. + * + * @param array $locs loc => lastmod (ISO-8601), empty string for none. + * @return string + */ + public static function urlsetWithLastmod( array $locs ): string { + $entries = ''; + foreach ( $locs as $loc => $lastmod ) { + $entries .= '' . htmlspecialchars( $loc, ENT_XML1 ) . ''; + if ( '' !== $lastmod ) { + $entries .= '' . htmlspecialchars( $lastmod, ENT_XML1 ) . ''; + } + $entries .= ''; + } + + return '' + . $entries . ''; + } + /** * A successful `wp_remote_get()`-shaped response array wrapping a body. * diff --git a/tests/Unit/BreezeWarmupSitemap/MergeUrlsTest.php b/tests/Unit/BreezeWarmupSitemap/MergeUrlsTest.php new file mode 100644 index 0000000..092b879 --- /dev/null +++ b/tests/Unit/BreezeWarmupSitemap/MergeUrlsTest.php @@ -0,0 +1,107 @@ +assertSame( 'https://example.test/', $result[0] ); + } + + public function test_breeze_only_entries_come_before_our_list(): void { + // Entries the admin typed but which are not in the sitemap cannot be + // scored, so they sit right behind the homepage — matching the fact + // that `manual` is the second highest weight. + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/', 'https://example.test/akce/' ), + array( 'https://example.test/kontakt/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/', 'https://example.test/akce/', 'https://example.test/kontakt/' ), + $result + ); + } + + public function test_our_ordering_is_preserved(): void { + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/' ), + array( 'https://example.test/first/', 'https://example.test/second/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/', 'https://example.test/first/', 'https://example.test/second/' ), + $result + ); + } + + public function test_homepage_is_never_duplicated(): void { + // Breeze builds it with trailingslashit(); a sitemap may emit it + // without the slash. Keyed on the raw string those are two URLs. + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/' ), + array( 'https://example.test', 'https://example.test/a/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/', 'https://example.test/a/' ), + $result + ); + } + + public function test_dedup_uses_canonical_keys(): void { + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/', 'https://example.test/akce' ), + array( 'https://example.test/akce/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/', 'https://example.test/akce' ), + $result + ); + } + + public function test_homepage_missing_from_breeze_list_is_not_invented(): void { + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/shop/' ), + array( 'https://example.test/a/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/shop/', 'https://example.test/a/' ), + $result + ); + } + + public function test_empty_ordered_list_returns_breeze_list_unchanged(): void { + $existing = array( 'https://example.test/', 'https://example.test/shop/' ); + + $this->assertSame( $existing, BreezeWarmupSitemap::mergeUrls( $existing, array(), self::HOME ) ); + } +} diff --git a/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php b/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php new file mode 100644 index 0000000..7c6ad5f --- /dev/null +++ b/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php @@ -0,0 +1,198 @@ +returnArg( 2 ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'add_action' )->justReturn( true ); + + BreezeWarmupSitemap::register( true ); + } + + public function test_reorders_from_stored_signals_without_touching_the_network(): void { + $this->enablePriority(); + + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/a/', 'https://example.test/b/' ), + 'signals' => array( + 'https://example.test/a/' => array( + 'lastmod' => null, 'type' => '', 'lang' => 'cs', + 'menu' => false, 'front_page' => false, 'manual' => false, + 'url' => 'https://example.test/a/', + ), + 'https://example.test/b/' => array( + 'lastmod' => null, 'type' => '', 'lang' => 'cs', + 'menu' => false, 'front_page' => false, 'manual' => false, + 'url' => 'https://example.test/b/', + ), + ), + 'fetched_at' => time(), + 'weights_hash' => 'h', + 'revision' => 1, + ) + ); + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 3 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->justReturn( + array( (object) array( 'url' => 'https://example.test/b/' ) ) + ); + Functions\expect( 'wp_remote_get' )->never(); + + $written = null; + Functions\when( 'update_option' )->alias( + static function ( string $key, $value ) use ( &$written ): bool { + $written = $value; + + return true; + } + ); + + BreezeWarmupSitemap::rescoreOnMenuUpdate(); + + $this->assertSame( 'https://example.test/b/', $written['urls'][0], 'the new menu page leads' ); + } + + public function test_does_nothing_when_signals_are_missing(): void { + $this->enablePriority(); + + // A v1.x payload, or a refresh that has not run yet. Partial data must + // never be written: stale data beats no data, same as runRefresh(). + Functions\when( 'get_option' )->justReturn( + array( 'urls' => array( 'https://example.test/a/' ), 'fetched_at' => time() ) + ); + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + Functions\when( 'get_transient' )->justReturn( false ); + Functions\when( 'set_transient' )->justReturn( true ); + Functions\when( 'wp_schedule_single_event' )->justReturn( true ); + Functions\expect( 'update_option' )->never(); + + BreezeWarmupSitemap::rescoreOnMenuUpdate(); + + $this->addToAssertionCount( 1 ); + } + + public function test_register_true_hooks_the_rescore_at_priority_5(): void { + $actions = array(); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'add_action' )->alias( + function ( string $tag, $callback, int $priority = 10 ) use ( &$actions ) { + $actions[] = array( $tag, $priority ); + + return true; + } + ); + + BreezeWarmupSitemap::register( true ); + + $this->assertContains( array( 'wp_update_nav_menu', 5 ), $actions ); + } + + public function test_register_false_does_not_hook_the_rescore(): void { + $actions = array(); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'add_action' )->alias( + function ( string $tag ) use ( &$actions ) { + $actions[] = $tag; + + return true; + } + ); + + BreezeWarmupSitemap::register(); + + $this->assertNotContains( 'wp_update_nav_menu', $actions ); + } + + public function test_a_throwing_collaborator_never_escapes_the_hook(): void { + $this->enablePriority(); + + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/a/' ), + 'signals' => array( + 'https://example.test/a/' => array( + 'lastmod' => null, 'type' => '', 'lang' => 'cs', + 'menu' => false, 'front_page' => false, 'manual' => false, + 'url' => 'https://example.test/a/', + ), + ), + 'fetched_at' => time(), + 'weights_hash' => 'h', + 'revision' => 1, + ) + ); + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 3 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->alias( + static function () { + throw new \RuntimeException( 'nav menu lookup exploded' ); + } + ); + + BreezeWarmupSitemap::rescoreOnMenuUpdate(); + + $this->addToAssertionCount( 1 ); + } + + public function test_register_false_leaves_rescore_unreachable_even_with_stored_signals(): void { + // register() defaults to false: priority_enabled stays false, so + // rescoreOnMenuUpdate() must no-op regardless of what is stored. + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/a/' ), + 'signals' => array( + 'https://example.test/a/' => array( + 'lastmod' => null, 'type' => '', 'lang' => 'cs', + 'menu' => false, 'front_page' => false, 'manual' => false, + 'url' => 'https://example.test/a/', + ), + ), + 'fetched_at' => time(), + 'weights_hash' => 'h', + 'revision' => 1, + ) + ); + Functions\expect( 'update_option' )->never(); + Functions\expect( 'wp_remote_get' )->never(); + + BreezeWarmupSitemap::rescoreOnMenuUpdate(); + + $this->addToAssertionCount( 1 ); + } +} diff --git a/tests/Unit/BreezeWarmupSitemap/RunRefreshPriorityTest.php b/tests/Unit/BreezeWarmupSitemap/RunRefreshPriorityTest.php new file mode 100644 index 0000000..661876e --- /dev/null +++ b/tests/Unit/BreezeWarmupSitemap/RunRefreshPriorityTest.php @@ -0,0 +1,121 @@ +record( 'https://example.test/plain/' ), + $this->record( 'https://example.test/kontakt/', array( 'menu' => true ) ), + ); + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 50 ); + + $this->assertSame( 'https://example.test/kontakt/', $built['urls'][0] ); + } + + public function test_front_page_leads(): void { + $records = array( + $this->record( 'https://example.test/kontakt/', array( 'menu' => true ) ), + $this->record( 'https://example.test/', array( 'front_page' => true ) ), + ); + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 50 ); + + $this->assertSame( 'https://example.test/', $built['urls'][0] ); + } + + public function test_signals_are_stored_keyed_by_canonical_url(): void { + $records = array( $this->record( 'https://example.test/a/', array( 'menu' => true ) ) ); + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 50 ); + + $this->assertArrayHasKey( 'https://example.test/a/', $built['signals'] ); + $this->assertTrue( $built['signals']['https://example.test/a/']['menu'] ); + } + + public function test_stored_signals_include_manual(): void { + // Without this the menu rescore would lose the manual weight and push + // hand-picked URLs down the list. + $records = array( $this->record( 'https://example.test/akce/', array( 'manual' => true ) ) ); + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 50 ); + + $this->assertTrue( $built['signals']['https://example.test/akce/']['manual'] ); + } + + public function test_cap_is_applied(): void { + $records = array(); + for ( $i = 0; $i < 10; $i++ ) { + $records[] = $this->record( 'https://example.test/' . $i . '/' ); + } + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 3 ); + + $this->assertCount( 3, $built['urls'] ); + } + + public function test_refresh_releases_the_lock_on_failure(): void { + Functions\when( 'get_option' )->justReturn( null ); + Functions\when( 'home_url' )->justReturn( '' ); + Functions\when( 'get_transient' )->justReturn( false ); + Functions\when( 'set_transient' )->justReturn( true ); + Functions\expect( 'delete_transient' )->once(); + + BreezeWarmupSitemap::runRefresh(); + + // Functions\expect()->once() above is the real assertion (verified on + // Mockery::close() in Monkey\tearDown()); this just keeps PHPUnit from + // flagging the test as risky for having no assertions of its own. + $this->addToAssertionCount( 1 ); + } + + /** + * @param array $overrides + * @return array + */ + private function record( string $url, array $overrides = array() ): array { + return array_merge( + array( + 'url' => $url, + 'key' => $url, + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'source' => 'https://example.test/wp-sitemap.xml', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ), + $overrides + ); + } +} diff --git a/tests/Unit/BreezeWarmupSitemap/RunRefreshTest.php b/tests/Unit/BreezeWarmupSitemap/RunRefreshTest.php index 25277e2..d5171bf 100644 --- a/tests/Unit/BreezeWarmupSitemap/RunRefreshTest.php +++ b/tests/Unit/BreezeWarmupSitemap/RunRefreshTest.php @@ -26,6 +26,17 @@ protected function setUp(): void { Functions\when( 'is_wp_error' )->justReturn( false ); Functions\when( 'wp_remote_retrieve_response_code' )->alias( fn( $r ) => $r['response']['code'] ?? 200 ); Functions\when( 'wp_remote_retrieve_body' )->alias( fn( $r ) => $r['body'] ?? '' ); + // runRefresh() now also calls SignalCollector::* (via enrichRecords()) + // and apply_filters() (via weights()/maxUrls()) on a non-empty crawl. + // Both must be stubbed here regardless of whether this test's own + // assertions touch them: Brain\Monkey/Patchwork redefines a function + // process-wide the first time ANY test mocks it, so a later test in + // the same run can no longer rely on function_exists() being false — + // see the Brain\Monkey caveat in AGENTS.md. returnArg( 2 ) mirrors + // WordPress's own apply_filters() no-op default (return $value + // unchanged) for every filter this pipeline calls. + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( 'wp_get_nav_menus' )->justReturn( array() ); } protected function tearDown(): void { @@ -38,6 +49,9 @@ public function test_successful_refresh_stores_urls_and_releases_lock(): void { Functions\when( 'wp_remote_get' )->justReturn( Fixtures::response( Fixtures::urlset( array( 'https://example.test/fresh/' ) ) ) ); + // Empty store: PriorityStore::revision() needs a get_option() stub to + // read the current revision (0 here) before write() can proceed. + Functions\when( 'get_option' )->justReturn( null ); $updateOptionCalls = array(); Functions\when( 'update_option' )->alias( @@ -61,6 +75,10 @@ function ( $key ) use ( &$deletedTransients ) { $this->assertSame( 'timber_kit_breeze_warmup_sitemap_urls', $key ); $this->assertSame( array( 'https://example.test/fresh/' ), $value['urls'] ); $this->assertIsInt( $value['fetched_at'] ); + $this->assertIsArray( $value['signals'] ); + $this->assertIsString( $value['weights_hash'] ); + $this->assertNotSame( '', $value['weights_hash'] ); + $this->assertIsInt( $value['revision'] ); $this->assertFalse( $autoload ); $this->assertSame( array( 'timber_kit_breeze_warmup_sitemap_refresh_lock' ), $deletedTransients ); } @@ -68,6 +86,10 @@ function ( $key ) use ( &$deletedTransients ) { public function test_failed_crawl_does_not_overwrite_stored_data(): void { Functions\when( 'wp_remote_get' )->justReturn( 'error-marker' ); Functions\when( 'is_wp_error' )->justReturn( true ); + // PriorityStore::revision() reads get_option() before runRefresh() + // even reaches the crawl outcome — stub it so that read doesn't blow + // up; the empty store (null) is otherwise irrelevant to this test. + Functions\when( 'get_option' )->justReturn( null ); Functions\expect( 'update_option' )->never(); $deletedTransients = array(); @@ -85,6 +107,9 @@ function ( $key ) use ( &$deletedTransients ) { public function test_empty_sitemap_result_does_not_overwrite_stored_data(): void { Functions\when( 'wp_remote_get' )->justReturn( Fixtures::response( Fixtures::urlset( array() ) ) ); + // Same as above: only needed so PriorityStore::revision() has + // something to read; the empty store itself is not under test here. + Functions\when( 'get_option' )->justReturn( null ); Functions\expect( 'update_option' )->never(); $deletedTransients = array(); diff --git a/tests/Unit/Health/Check/PreloadChainHealthyTest.php b/tests/Unit/Health/Check/PreloadChainHealthyTest.php new file mode 100644 index 0000000..c7384f3 --- /dev/null +++ b/tests/Unit/Health/Check/PreloadChainHealthyTest.php @@ -0,0 +1,107 @@ +returnArg(); + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + public function test_identity(): void { + $check = new PreloadChainHealthy(); + + $this->assertSame( 'preload_chain_healthy', $check->id() ); + $this->assertSame( 'caching', $check->category() ); + $this->assertSame( HealthCheck::METHOD_EFFECT, $check->method() ); + } + + public function test_empty_queue_passes(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key ? array() : 0 + ); + + $this->assertSame( Result::GOOD, ( new PreloadChainHealthy() )->run()->status() ); + } + + public function test_moving_queue_passes(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key + ? array( 'https://example.test/a/' ) + : time() - 5 + ); + + $this->assertSame( Result::GOOD, ( new PreloadChainHealthy() )->run()->status() ); + } + + public function test_stalled_queue_fails(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key + ? array( 'https://example.test/a/', 'https://example.test/b/' ) + : time() - 600 + ); + + $this->assertSame( Result::CRITICAL, ( new PreloadChainHealthy() )->run()->status() ); + } + + public function test_never_warmed_queue_fails_without_a_nonsensical_elapsed_time(): void { + Functions\when( 'get_option' )->alias( + static function ( string $key, mixed $default = false ) { + if ( 'breeze_preload_queue' === $key ) { + return array( 'https://example.test/a/' ); + } + + // breeze_preload_last_warm is absent; get_option falls back to $default. + return $default; + } + ); + + $result = ( new PreloadChainHealthy() )->run(); + + $this->assertSame( Result::CRITICAL, $result->status() ); + $this->assertDoesNotMatchRegularExpression( '/\d{5,}/', $result->summary() ); + } + + public function test_idle_exactly_at_the_stall_boundary_passes(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key + ? array( 'https://example.test/a/' ) + : time() - 60 + ); + + $this->assertSame( Result::GOOD, ( new PreloadChainHealthy() )->run()->status() ); + } + + public function test_idle_one_second_past_the_stall_boundary_fails(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key + ? array( 'https://example.test/a/' ) + : time() - 61 + ); + + $this->assertSame( Result::CRITICAL, ( new PreloadChainHealthy() )->run()->status() ); + } +} diff --git a/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php b/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php new file mode 100644 index 0000000..26b0ada --- /dev/null +++ b/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php @@ -0,0 +1,110 @@ + $actions + * @return void + */ + private function captureActions( array &$actions ): void { + Functions\when( 'add_action' )->alias( + function ( string $tag, $callback = null, int $priority = 10 ) use ( &$actions ) { + $actions[] = array( $tag, $priority ); + + return true; + } + ); + Functions\when( 'add_filter' )->justReturn( true ); + } + + public function test_priority_off_does_not_wire_the_menu_hook(): void { + $actions = array(); + $this->captureActions( $actions ); + + BreezeWarmupSitemap::register( false, null ); + + $this->assertNotContains( 'wp_update_nav_menu', array_column( $actions, 0 ) ); + } + + public function test_priority_on_wires_the_menu_hook_at_priority_five(): void { + // Priority 5 so the rescore lands before both Breeze's own menu purge + // and the kit's, which sit at 10. + $actions = array(); + $this->captureActions( $actions ); + + BreezeWarmupSitemap::register( true, null ); + + $this->assertContains( array( 'wp_update_nav_menu', 5 ), $actions ); + } + + public function test_the_filter_wins_over_the_declared_weights(): void { + // How many times the filter fires is an implementation detail, not + // part of this contract — only pin what wins. + Filters\expectApplied( 'timberkit_warmup_priority_weights' ) + ->andReturn( array( 'menu' => 42 ) ); + + BreezeWarmupSitemap::register( true, array( 'menu' => 7 ) ); + + $this->assertSame( 42, BreezeWarmupSitemap::weights()['menu'] ); + } + + /** + * Pins the actual contract behind `weightsChanged()`: the hash computed + * once at registration and the hash a refresh write would store both + * derive from the FILTERED weights. If registration ever hashed the raw, + * unfiltered weights instead, a project using the + * `timberkit_warmup_priority_weights` filter would see these two values + * permanently disagree — `weightsChanged()` would report a mismatch on + * every single purge, scheduling a needless sitemap refresh forever. + */ + public function test_registration_hash_agrees_with_what_a_refresh_write_would_store(): void { + Filters\expectApplied( 'timberkit_warmup_priority_weights' ) + ->andReturn( array( 'menu' => 42 ) ); + + BreezeWarmupSitemap::register( true, array( 'menu' => 7 ) ); + + $reflection = new \ReflectionClass( BreezeWarmupSitemap::class ); + $registeredHash = $reflection->getProperty( 'weights_hash' ); + $registeredHash->setAccessible( true ); + + $writeWouldStore = \Parisek\TimberKit\BreezeWarmup\Scorer::weightsHash( BreezeWarmupSitemap::weights() ); + + $this->assertSame( $writeWouldStore, $registeredHash->getValue() ); + } +} diff --git a/tests/Unit/StarterBase/BreezeWarmupSitemapSetupTest.php b/tests/Unit/StarterBase/BreezeWarmupSitemapSetupTest.php index 4c4f7b7..1febc03 100644 --- a/tests/Unit/StarterBase/BreezeWarmupSitemapSetupTest.php +++ b/tests/Unit/StarterBase/BreezeWarmupSitemapSetupTest.php @@ -67,6 +67,14 @@ function ( string $tag ) use ( &$filters ) { $this->assertNotContains( 'breeze_preload_urls', $filters ); } + // This test's assertion depends on function_exists( 'breeze_get_option' ) + // returning FALSE — the same check AGENTS.md calls unreliable under + // Brain\Monkey, because any other test in the run that mocks + // breeze_get_option() patches it in for the rest of the process, which + // makes the "Breeze absent" case this test asserts unobservable. It must + // run isolated regardless of who else is in the suite. + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] public function test_does_not_register_when_flag_is_on_but_breeze_is_absent(): void { $filters = array(); Functions\when( 'add_filter' )->alias(