Doubles
1 week ago
Providers
1 week ago
BulkRearmOnGrowthTest.php
1 week ago
BulkReviewsUpdateStuckStateTest.php
1 week ago
ClearCacheRelayResetTest.php
1 week ago
DeleteSourceRelayFailureTest.php
1 week ago
ErrorHandlerFalsyOptionTest.php
1 week ago
FeedCacheUpdateServiceTest.php
1 week ago
FeedMalformedPayloadTest.php
1 week ago
ForceKeylessRefetchTest.php
1 week ago
LicenseDeactivateStaleStateTest.php
1 week ago
MediaFinderMemoTest.php
1 week ago
MultiSourceAggregationTest.php
1 week ago
ReconcileMigratedLicenseRoutineTest.php
1 week ago
ReconcileRemovalTest.php
1 week ago
RegisterWebsiteRoutineTest.php
1 week ago
RelaySlowEndpointsTest.php
1 week ago
RemoteRequestMemoTest.php
1 week ago
ReviewAlertHeaderTotalsTest.php
1 week ago
ReviewAlertPageTargetingTest.php
1 week ago
ReviewAlertStarFillTest.php
1 week ago
ShortcodeNeutralizationTest.php
1 week ago
SiteMigrationRecoveryTest.php
1 week ago
Smash1130UsageTrackingHardeningTest.php
1 week ago
Smash1130UsageTrackingHooksTest.php
1 week ago
Smash1583HeaderParityTest.php
1 week ago
Smash1631MultiLanguageBulkTest.php
1 week ago
Smash1631UpdateSingleLangScopeTest.php
1 week ago
Smash1706TripAdvisorPlaceIdTest.php
1 week ago
Smash1756SchemaServiceTest.php
1 week ago
Smash1785AvatarLocalUrlGuardTest.php
1 week ago
Smash1785AvatarReHealTest.php
1 week ago
Smash1795ReviewTextXssTest.php
1 week ago
Smash1835TripAdvisorKeyShapeTest.php
1 week ago
Smash1973WordpressOrgPlaceIdNullTest.php
1 week ago
Smash1987NestedSourceErrorShapeTest.php
1 week ago
Smash782BookingHeaderRatingTest.php
1 week ago
Smash782CountryFlagEmojiTest.php
1 week ago
Smash782ExternalRefreshCronTest.php
1 week ago
Smash782ExtrasTemplateTest.php
1 week ago
Smash782ReviewAlertProviderDataTest.php
1 week ago
SourceIdLookupTest.php
1 week ago
WpmlGetCurrentLanguageTest.php
1 week ago
WpmlLanguageMappingTest.php
1 week ago
Smash1756SchemaServiceTest.php
858 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SmashBalloon\Reviews\Tests\Unit; |
| 4 | |
| 5 | use PHPUnit\Framework\TestCase; |
| 6 | use SmashBalloon\Reviews\Common\SBR_Schema_Service; |
| 7 | |
| 8 | // home_url() / get_bloginfo() stubs live in tests/bootstrap.php (global namespace) |
| 9 | // so the service's unqualified calls resolve to them. |
| 10 | |
| 11 | /** |
| 12 | * SMASH-1756 — schema.org rich-snippet output. |
| 13 | * |
| 14 | * Covers the two unit-testable cores: feed detection from page content, and the |
| 15 | * pure feed→schema mapper. Plus the enable gate and the JSON-LD escaping. |
| 16 | */ |
| 17 | class Smash1756SchemaServiceTest extends TestCase |
| 18 | { |
| 19 | /** @var SBR_Schema_Service */ |
| 20 | private $service; |
| 21 | |
| 22 | protected function setUp(): void |
| 23 | { |
| 24 | parent::setUp(); |
| 25 | $GLOBALS['wp_options_mock'] = []; |
| 26 | $GLOBALS['wp_filter_mock'] = []; |
| 27 | // Pin home_url so the assertion is deterministic regardless of test order |
| 28 | // (a sibling test may leave $wp_home_url_mock set). |
| 29 | $GLOBALS['wp_home_url_mock'] = 'https://example.test'; |
| 30 | $this->service = new SBR_Schema_Service(); |
| 31 | } |
| 32 | |
| 33 | // ---------- detect_feed_ids ---------- |
| 34 | |
| 35 | public function test_detect_feed_ids_from_shortcode(): void |
| 36 | { |
| 37 | $this->assertSame([12], $this->service->detect_feed_ids('<p>Hi</p>[reviews-feed feed=12]')); |
| 38 | $this->assertSame([12], $this->service->detect_feed_ids('[reviews-feed feed="12"]')); |
| 39 | } |
| 40 | |
| 41 | public function test_detect_feed_ids_from_block_json(): void |
| 42 | { |
| 43 | // Gutenberg stores the block's shortcode args JSON-escaped in the comment. |
| 44 | $content = '<!-- wp:sbr/sbr-feed-block {"shortcodeSettings":"feed=\\"34\\" num=6"} /-->'; |
| 45 | $this->assertSame([34], $this->service->detect_feed_ids($content)); |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * REGRESSION (found on a QA site, not by these tests): the MODERN Gutenberg block |
| 50 | * `smashballoon/reviews-feed` serialises its id as `{"feedId":"3"}` — note the |
| 51 | * closing quote of the key sits between `feedId` and the colon. The original |
| 52 | * pattern required `feed` to be followed directly by `=` or `:`, so it matched |
| 53 | * neither `feedId` nor that quote, detection returned [], and `setup()` bailed — |
| 54 | * a page using the block emitted NO schema at all, silently. |
| 55 | * |
| 56 | * That is the default way most people insert a feed, so this was the majority case. |
| 57 | */ |
| 58 | public function test_detect_feed_ids_from_modern_gutenberg_block(): void |
| 59 | { |
| 60 | $this->assertSame( |
| 61 | [3], |
| 62 | $this->service->detect_feed_ids('<!-- wp:smashballoon/reviews-feed {"feedId":"3"} /-->'), |
| 63 | 'modern block must be detected — it is the default insertion path' |
| 64 | ); |
| 65 | |
| 66 | // With the full attribute set the block actually writes. |
| 67 | $this->assertSame( |
| 68 | [41], |
| 69 | $this->service->detect_feed_ids( |
| 70 | '<!-- wp:smashballoon/reviews-feed {"blockId":"a1b2","feedId":"41","preview":false} /-->' |
| 71 | ) |
| 72 | ); |
| 73 | |
| 74 | // And when the whole block comment is itself JSON-escaped one level deeper. |
| 75 | $this->assertSame([17], $this->service->detect_feed_ids('reviews-feed {\\"feedId\\":\\"17\\"}')); |
| 76 | } |
| 77 | |
| 78 | /** An unconfigured block has `feedId: ""` — there is no feed to look up. */ |
| 79 | public function test_detect_feed_ids_ignores_unconfigured_modern_block(): void |
| 80 | { |
| 81 | $this->assertSame( |
| 82 | [], |
| 83 | $this->service->detect_feed_ids('<!-- wp:smashballoon/reviews-feed {"feedId":""} /-->') |
| 84 | ); |
| 85 | } |
| 86 | |
| 87 | /** All three placement forms coexisting on one page, deduped and in order. */ |
| 88 | public function test_detect_feed_ids_across_all_placement_forms(): void |
| 89 | { |
| 90 | $content = '[reviews-feed feed=3]' |
| 91 | . '<!-- wp:sbr/sbr-feed-block {"shortcodeSettings":"feed=\\"7\\""} /-->' |
| 92 | . '<!-- wp:smashballoon/reviews-feed {"feedId":"41"} /-->' |
| 93 | . '[reviews-feed feed="3"]'; |
| 94 | |
| 95 | $this->assertSame([3, 7, 41], $this->service->detect_feed_ids($content)); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * REGRESSION: the Elementor widget's control is `feed_id` (underscore), and its data |
| 100 | * lives in the `_elementor_data` postmeta rather than post_content. Verified on a |
| 101 | * local page: the feed rendered but the graph carried no rated node at all. |
| 102 | * setup() now feeds detect_feed_ids() a haystack that includes the builder data — |
| 103 | * this covers the key-name half. |
| 104 | */ |
| 105 | public function test_detect_feed_ids_from_elementor_widget(): void |
| 106 | { |
| 107 | // Shape Elementor stores (already slash-escaped as it comes out of postmeta). |
| 108 | $data = '[{"id":"c1","elType":"container","elements":[{"id":"w1","elType":"widget",' |
| 109 | . '"widgetType":"sb-reviews-feed","settings":{"feed_id":"183"}}]}]'; |
| 110 | |
| 111 | $this->assertSame( |
| 112 | [183], |
| 113 | $this->service->detect_feed_ids($data), |
| 114 | 'Elementor control is feed_id — with an underscore' |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | /** All four placement forms at once — shortcode, both blocks, Elementor. */ |
| 119 | public function test_detect_feed_ids_across_every_placement_form(): void |
| 120 | { |
| 121 | $content = '[reviews-feed feed=3]' |
| 122 | . '<!-- wp:sbr/sbr-feed-block {"shortcodeSettings":"feed=\\"7\\""} /-->' |
| 123 | . '<!-- wp:smashballoon/reviews-feed {"feedId":"41"} /-->' |
| 124 | . ' {"widgetType":"sb-reviews-feed","settings":{"feed_id":"183"}}'; |
| 125 | |
| 126 | $this->assertSame([3, 7, 41, 183], $this->service->detect_feed_ids($content)); |
| 127 | } |
| 128 | |
| 129 | /** Shortcode spacing/quoting variants a user might actually type. */ |
| 130 | public function test_detect_feed_ids_shortcode_variants(): void |
| 131 | { |
| 132 | $this->assertSame([5], $this->service->detect_feed_ids('[reviews-feed feed = 5 ]')); |
| 133 | $this->assertSame([8], $this->service->detect_feed_ids("[reviews-feed feed='8']")); |
| 134 | $this->assertSame([9], $this->service->detect_feed_ids('[reviews-feed feed=9 num=5]')); |
| 135 | } |
| 136 | |
| 137 | public function test_detect_feed_ids_multiple_unique(): void |
| 138 | { |
| 139 | $content = '[reviews-feed feed=1] and [reviews-feed feed="2"] and again [reviews-feed feed=1]'; |
| 140 | $this->assertSame([1, 2], $this->service->detect_feed_ids($content)); |
| 141 | } |
| 142 | |
| 143 | public function test_detect_feed_ids_ignores_unrelated_content(): void |
| 144 | { |
| 145 | // No reviews-feed marker → never scans, even with a stray feed=. |
| 146 | $this->assertSame([], $this->service->detect_feed_ids('<a href="?feed=99">rss</a>')); |
| 147 | $this->assertSame([], $this->service->detect_feed_ids('')); |
| 148 | $this->assertSame([], $this->service->detect_feed_ids('plain content')); |
| 149 | } |
| 150 | |
| 151 | // ---------- map_feed_to_schema ---------- |
| 152 | |
| 153 | private function header(float $rating, int $count, string $name = 'Acme Co'): array |
| 154 | { |
| 155 | return [['name' => $name, 'info' => ['rating' => $rating, 'total_rating' => $count]]]; |
| 156 | } |
| 157 | |
| 158 | private function review(string $author, int $rating, string $text, int $time = 1600000000): array |
| 159 | { |
| 160 | return ['reviewer' => ['name' => $author], 'rating' => $rating, 'text' => $text, 'time' => $time]; |
| 161 | } |
| 162 | |
| 163 | /** Booking-shaped header: native 0-10 score in info.review_score (not info.rating). */ |
| 164 | private function booking_header(float $score, int $count, string $word = 'Fabulous'): array |
| 165 | { |
| 166 | return [['name' => 'Grand Hotel', 'info' => [ |
| 167 | 'review_score' => $score, |
| 168 | 'review_score_word' => $word, |
| 169 | 'review_count' => $count, |
| 170 | 'total_rating' => $count, |
| 171 | ]]]; |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Booking-shaped review, matching the real cached payload: the card badge score |
| 176 | * lives in metadata.review_score and is the PROPERTY's overall score (identical |
| 177 | * on every review the relay returns), while `rating` carries that reviewer's own |
| 178 | * score normalised to 0-5. Verified against live data (SMASH-1793): eight |
| 179 | * consecutive reviews of one hotel all had review_score 9.4 while their ratings |
| 180 | * were 5, 5, 4.5, 5, 5, 4, 5, 4. |
| 181 | * |
| 182 | * $own_rating defaults to a value distinct from the property score so a test can |
| 183 | * tell which one (if either) reached the markup. |
| 184 | */ |
| 185 | private function booking_review(string $author, float $property_score, string $text, int $time = 1600000000, float $own_rating = 5.0): array |
| 186 | { |
| 187 | return [ |
| 188 | 'reviewer' => ['name' => $author], |
| 189 | 'provider' => ['name' => 'booking'], |
| 190 | 'metadata' => ['review_score' => $property_score], |
| 191 | 'rating' => $own_rating, |
| 192 | 'text' => $text, |
| 193 | 'time' => $time, |
| 194 | ]; |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Booking review as the relay emits it when the source lookup FAILS. Two real |
| 199 | * shapes, both verified in sb-relay: `review_score => null` (the key is stamped |
| 200 | * unconditionally from a `?? null`, and the failure branch hardcodes null), and |
| 201 | * the key absent entirely (the whole metadata loop is skipped when |
| 202 | * getSourceInfo() returns a non-array). |
| 203 | * |
| 204 | * These are the shapes a presence-based gate misses, because isset() is false |
| 205 | * for null — they used to fall through and emit the author's invisible 0-5 |
| 206 | * rating, or a fabricated 1 star when `rating` was empty too. |
| 207 | * |
| 208 | * @param mixed $score null, or a non-numeric value; omit $has_key to drop it. |
| 209 | */ |
| 210 | private function booking_review_degraded(string $author, $score = null, bool $has_key = true, ?float $own_rating = 4.0): array |
| 211 | { |
| 212 | $post = [ |
| 213 | 'reviewer' => ['name' => $author], |
| 214 | 'provider' => ['name' => 'booking'], |
| 215 | 'text' => 'Stayed here last week', |
| 216 | 'time' => 1600000000, |
| 217 | ]; |
| 218 | if ($has_key) { |
| 219 | $post['metadata'] = ['review_score' => $score]; |
| 220 | } |
| 221 | if ($own_rating !== null) { |
| 222 | $post['rating'] = $own_rating; |
| 223 | } |
| 224 | return $post; |
| 225 | } |
| 226 | |
| 227 | public function test_map_localbusiness_with_aggregate_and_reviews(): void |
| 228 | { |
| 229 | $nodes = $this->service->map_feed_to_schema( |
| 230 | $this->header(4.5, 10), |
| 231 | [$this->review('Jane', 5, 'Great service')], |
| 232 | [['provider' => 'google']] |
| 233 | ); |
| 234 | |
| 235 | $this->assertCount(1, $nodes); |
| 236 | $node = $nodes[0]; |
| 237 | $this->assertSame('LocalBusiness', $node['@type']); |
| 238 | $this->assertSame('Acme Co', $node['name']); |
| 239 | $this->assertSame('https://example.test/', $node['url']); |
| 240 | $this->assertSame('4.5', $node['aggregateRating']['ratingValue']); |
| 241 | $this->assertSame('10', $node['aggregateRating']['reviewCount']); |
| 242 | $this->assertSame('5', $node['aggregateRating']['bestRating']); |
| 243 | $this->assertSame('Review', $node['review'][0]['@type']); |
| 244 | $this->assertSame('Jane', $node['review'][0]['author']['name']); |
| 245 | $this->assertSame('5', $node['review'][0]['reviewRating']['ratingValue']); |
| 246 | $this->assertSame('Great service', $node['review'][0]['reviewBody']); |
| 247 | $this->assertSame(gmdate('c', 1600000000), $node['review'][0]['datePublished']); |
| 248 | } |
| 249 | |
| 250 | public function test_map_uses_info_name_url_image(): void |
| 251 | { |
| 252 | $header = [['info' => ['name' => 'Island Villa', 'url' => 'https://airbnb.test/rooms/1', 'image' => 'https://img.test/x.jpg', 'rating' => 4.9, 'total_rating' => 26]]]; |
| 253 | $nodes = $this->service->map_feed_to_schema($header, [$this->review('Zoe', 5, 'Lovely')], [['provider' => 'airbnb']]); |
| 254 | $this->assertSame('LodgingBusiness', $nodes[0]['@type']); |
| 255 | $this->assertSame('Island Villa', $nodes[0]['name']); |
| 256 | $this->assertSame('https://airbnb.test/rooms/1', $nodes[0]['url']); |
| 257 | $this->assertSame('https://img.test/x.jpg', $nodes[0]['image']); |
| 258 | } |
| 259 | |
| 260 | public function test_map_product_when_woocommerce_source(): void |
| 261 | { |
| 262 | $nodes = $this->service->map_feed_to_schema( |
| 263 | $this->header(4.0, 3), |
| 264 | [$this->review('Bob', 4, 'Good product')], |
| 265 | [['provider' => 'woocommerce']] |
| 266 | ); |
| 267 | $this->assertSame('Product', $nodes[0]['@type']); |
| 268 | // Product node must not carry the site url (that's the LocalBusiness path). |
| 269 | $this->assertArrayNotHasKey('url', $nodes[0]); |
| 270 | } |
| 271 | |
| 272 | public function test_map_product_when_edd_source(): void |
| 273 | { |
| 274 | $nodes = $this->service->map_feed_to_schema( |
| 275 | $this->header(5.0, 2), |
| 276 | [$this->review('Ann', 5, 'Nice')], |
| 277 | [['provider' => 'google'], ['provider' => 'edd']] |
| 278 | ); |
| 279 | $this->assertSame('Product', $nodes[0]['@type']); |
| 280 | } |
| 281 | |
| 282 | public function test_map_product_when_aliexpress_source(): void |
| 283 | { |
| 284 | // AliExpress is a marketplace product → Product (SMASH-1756 entity mapping). |
| 285 | $nodes = $this->service->map_feed_to_schema( |
| 286 | $this->header(4.7, 12), |
| 287 | [$this->review('Lee', 5, 'Fast shipping')], |
| 288 | [['provider' => 'aliexpress']] |
| 289 | ); |
| 290 | $this->assertSame('Product', $nodes[0]['@type']); |
| 291 | $this->assertArrayNotHasKey('url', $nodes[0]); // Product gets no synthetic site url |
| 292 | } |
| 293 | |
| 294 | public function test_map_lodgingbusiness_when_airbnb_source(): void |
| 295 | { |
| 296 | $nodes = $this->service->map_feed_to_schema( |
| 297 | $this->header(4.9, 26), |
| 298 | [$this->review('Zoe', 5, 'Lovely stay')], |
| 299 | [['provider' => 'airbnb']] |
| 300 | ); |
| 301 | $this->assertSame('LodgingBusiness', $nodes[0]['@type']); |
| 302 | // Place type with no source url → falls back to the site url. |
| 303 | $this->assertSame('https://example.test/', $nodes[0]['url']); |
| 304 | } |
| 305 | |
| 306 | public function test_map_booking_keeps_its_0_to_10_aggregate(): void |
| 307 | { |
| 308 | // AC #2 (SMASH-1793): the aggregate must NOT regress. Booking's native 0-10 |
| 309 | // header score is visible (feed header + every card badge) so it stays, on |
| 310 | // its own scale, with bestRating 10 rather than a 5-star conversion. |
| 311 | $nodes = $this->service->map_feed_to_schema( |
| 312 | $this->booking_header(8.9, 40), |
| 313 | [$this->booking_review('Max', 9.0, 'Great location')], |
| 314 | [['provider' => 'booking']] |
| 315 | ); |
| 316 | $node = $nodes[0]; |
| 317 | $this->assertSame('LodgingBusiness', $node['@type']); |
| 318 | $this->assertSame('8.9', $node['aggregateRating']['ratingValue']); |
| 319 | $this->assertSame('10', $node['aggregateRating']['bestRating']); |
| 320 | $this->assertSame('40', $node['aggregateRating']['reviewCount']); |
| 321 | $this->assertLessThanOrEqual( |
| 322 | (float) $node['aggregateRating']['bestRating'], |
| 323 | (float) $node['aggregateRating']['ratingValue'] |
| 324 | ); |
| 325 | } |
| 326 | |
| 327 | public function test_map_booking_emits_the_reviewers_own_score_on_the_0_to_10_scale(): void |
| 328 | { |
| 329 | // The Booking card's badge shows THIS reviewer's own score on Booking's 0-10 |
| 330 | // scale (rating-extras/booking.php → sbr_booking_review_score()). Schema marks |
| 331 | // up the same value on the same scale, so bestRating is 10. |
| 332 | $nodes = $this->service->map_feed_to_schema( |
| 333 | $this->booking_header(8.9, 40), |
| 334 | [$this->booking_review('Max', 9.0, 'Great location', 1600000000, 5.0)], |
| 335 | [['provider' => 'booking']] |
| 336 | ); |
| 337 | $review = $nodes[0]['review'][0]; |
| 338 | |
| 339 | $this->assertSame('Max', $review['author']['name']); |
| 340 | $this->assertSame('10', $review['reviewRating']['ratingValue']); |
| 341 | $this->assertSame('10', $review['reviewRating']['bestRating']); |
| 342 | $this->assertSame('1', $review['reviewRating']['worstRating']); |
| 343 | } |
| 344 | |
| 345 | public function test_map_booking_never_attributes_the_property_score_to_an_author(): void |
| 346 | { |
| 347 | // The regression this guard exists for: three reviewers, one property score. |
| 348 | // metadata.review_score is 9.4 on all three cards (the relay stamps the same |
| 349 | // pair onto every one), so a path that reads it publishes an identical 9.4 for |
| 350 | // each named person. Each Review must carry its OWN doubled rating instead. |
| 351 | $posts = [ |
| 352 | $this->booking_review('Josh', 9.4, 'Fantastic', 1600000000, 5.0), |
| 353 | $this->booking_review('Michael', 9.4, 'Superb', 1600000001, 4.5), |
| 354 | $this->booking_review('Paul', 9.4, 'Very good', 1600000002, 4.0), |
| 355 | ]; |
| 356 | $nodes = $this->service->map_feed_to_schema($this->booking_header(9.4, 1449), $posts, [['provider' => 'booking']]); |
| 357 | $node = $nodes[0]; |
| 358 | |
| 359 | $values = array_map( |
| 360 | static fn(array $r): string => $r['reviewRating']['ratingValue'], |
| 361 | $node['review'] |
| 362 | ); |
| 363 | $this->assertSame(['10', '9', '8'], $values); |
| 364 | // Distinct per author — the whole point. A property-score read collapses these. |
| 365 | $this->assertSame($values, array_values(array_unique($values))); |
| 366 | // The property score is visible on the header, so it may appear on the |
| 367 | // aggregate — but never inside a Review. |
| 368 | $this->assertSame('9.4', $node['aggregateRating']['ratingValue']); |
| 369 | $this->assertStringNotContainsString('9.4', (string) wp_json_encode($node['review'])); |
| 370 | } |
| 371 | |
| 372 | public function test_map_booking_publishes_the_score_the_card_actually_shows(): void |
| 373 | { |
| 374 | // Parity with the render layer: the same helper the template calls, so the |
| 375 | // marked-up number is character-for-character what the visitor reads on the |
| 376 | // badge. 4.5 stored → 9.0 shown → "9" published. |
| 377 | $posts = [$this->booking_review('Eva', 9.4, 'Lovely', 1600000000, 4.5)]; |
| 378 | $nodes = $this->service->map_feed_to_schema($this->booking_header(9.4, 100), $posts, [['provider' => 'booking']]); |
| 379 | |
| 380 | $this->assertSame('9', $nodes[0]['review'][0]['reviewRating']['ratingValue']); |
| 381 | $this->assertSame( |
| 382 | (string) sbr_booking_review_score($posts[0]), |
| 383 | $nodes[0]['review'][0]['reviewRating']['ratingValue'] |
| 384 | ); |
| 385 | } |
| 386 | |
| 387 | /** |
| 388 | * @dataProvider degraded_booking_payloads |
| 389 | * @param mixed $score |
| 390 | */ |
| 391 | public function test_map_booking_survives_a_degraded_relay_property_score($score, bool $has_key, string $case): void |
| 392 | { |
| 393 | // When the Booking source lookup fails the relay sends review_score as null, |
| 394 | // or omits it. The per-card score never depended on that field — it comes from |
| 395 | // the review's own rating — so a degraded property score must not cost us the |
| 396 | // Review node. (It does cost the AggregateRating; that one is genuinely the |
| 397 | // property's.) These four shapes are the ones verified in sb-relay. |
| 398 | $nodes = $this->service->map_feed_to_schema( |
| 399 | $this->booking_header(9.4, 1449), |
| 400 | [$this->booking_review_degraded('Josh', $score, $has_key)], |
| 401 | [['provider' => 'booking']] |
| 402 | ); |
| 403 | |
| 404 | // booking_review_degraded()'s own rating is 4.0 → 8.0 on Booking's scale. |
| 405 | $this->assertSame('8', $nodes[0]['review'][0]['reviewRating']['ratingValue'], $case); |
| 406 | $this->assertSame('10', $nodes[0]['review'][0]['reviewRating']['bestRating'], $case); |
| 407 | } |
| 408 | |
| 409 | /** @return array<string,array{0:mixed,1:bool,2:string}> */ |
| 410 | public static function degraded_booking_payloads(): array |
| 411 | { |
| 412 | return [ |
| 413 | 'review_score is null' => [null, true, 'review_score => null'], |
| 414 | 'review_score key is absent' => [null, false, 'metadata without review_score'], |
| 415 | 'review_score is non-numeric' => ['N/A', true, 'review_score => "N/A"'], |
| 416 | 'review_score is zero' => [0, true, 'review_score => 0'], |
| 417 | ]; |
| 418 | } |
| 419 | |
| 420 | public function test_rating_slot_substituted_providers_matches_the_render_layer(): void |
| 421 | { |
| 422 | // Drift guard. The invariant that matters is VISIBILITY: if a provider's CSS |
| 423 | // hides the per-review star block, that provider's reviews carry no rating |
| 424 | // the visitor can see, so schema must not claim one. Derive the list from the |
| 425 | // stylesheet itself rather than from the rating-extras templates — hiding the |
| 426 | // stars is what creates the defect; shipping a replacement badge is optional, |
| 427 | // and keying on the template would let a CSS-only provider slip through. |
| 428 | // rating.php's docblock already names Facebook as a planned second case, so |
| 429 | // this is a live risk rather than a hypothetical one. |
| 430 | $root = dirname(__DIR__, 2); |
| 431 | |
| 432 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local stylesheet on disk, not a remote URL |
| 433 | $css = (string) file_get_contents($root . '/assets/css/sbr-styles.css'); |
| 434 | |
| 435 | // Match on the rule BODY containing display:none, not on selector presence. |
| 436 | // The stylesheet already carries cosmetic provider-scoped rating rules (e.g. |
| 437 | // `.sbr-provider-booking .sb-item-rating { padding-top: 0 }`), so a selector |
| 438 | // match alone would fail this test for a harmless rule — and the failure |
| 439 | // message would push the next author to add that provider to the constant, |
| 440 | // silently stripping its Review nodes. Reading the body keeps the guard tied |
| 441 | // to the actual invariant: the stars are not visible. Scanning the whole |
| 442 | // selector text also covers `>` combinators and comma-separated lists. |
| 443 | // Strip comments FIRST. The selector capture below reaches back to the previous |
| 444 | // `}`, which would otherwise swallow the comment block above a rule — and this |
| 445 | // stylesheet's comments name providers in prose ("Facebook recommendation |
| 446 | // chip"). A slug mentioned only in a comment would be extracted as |
| 447 | // substituting, and this test's failure message would then tell the next |
| 448 | // author to add it to the constant, silently stripping that provider's Review |
| 449 | // nodes in production. The guard must not be able to cause the bug it guards. |
| 450 | $css = (string) preg_replace('#/\*.*?\*/#s', '', $css); |
| 451 | |
| 452 | $substituting = []; |
| 453 | preg_match_all('/([^{}]*sb-item-rating-ctn[^{}]*)\{([^}]*)\}/i', $css, $rules, PREG_SET_ORDER); |
| 454 | foreach ($rules as $rule) { |
| 455 | if (preg_match('/display\s*:\s*none/i', $rule[2]) !== 1) { |
| 456 | continue; |
| 457 | } |
| 458 | if (preg_match_all('/sbr-provider-([a-z0-9_-]+)/i', $rule[1], $found)) { |
| 459 | foreach ($found[1] as $provider) { |
| 460 | $substituting[] = strtolower($provider); |
| 461 | } |
| 462 | } |
| 463 | } |
| 464 | $substituting = array_values(array_unique($substituting)); |
| 465 | sort($substituting); |
| 466 | |
| 467 | $declared = (new \ReflectionClass(SBR_Schema_Service::class)) |
| 468 | ->getConstant('RATING_SLOT_SUBSTITUTED_PROVIDERS'); |
| 469 | $declared = is_array($declared) ? $declared : []; |
| 470 | sort($declared); |
| 471 | |
| 472 | $this->assertSame( |
| 473 | $substituting, |
| 474 | $declared, |
| 475 | 'A provider that replaces the star slot must be listed in ' |
| 476 | . 'RATING_SLOT_SUBSTITUTED_PROVIDERS, otherwise its reviews emit a rating ' |
| 477 | . 'that is not visible on the card.' |
| 478 | ); |
| 479 | } |
| 480 | |
| 481 | public function test_map_booking_never_fabricates_a_one_star_review(): void |
| 482 | { |
| 483 | // No score from the relay AND no rating on the review — nothing is visible on |
| 484 | // the card, so nothing is attributable. Note this can't go through |
| 485 | // Parser::get_rating(), which returns 1 for an unrated review and would |
| 486 | // publish a fabricated 1-star attributed by name to a real person; |
| 487 | // sbr_booking_review_score() returns 0 for a missing rating and we drop it. |
| 488 | $nodes = $this->service->map_feed_to_schema( |
| 489 | $this->booking_header(9.4, 1449), |
| 490 | [$this->booking_review_degraded('Josh', null, false, null)], |
| 491 | [['provider' => 'booking']] |
| 492 | ); |
| 493 | |
| 494 | $encoded = (string) wp_json_encode($nodes[0]); |
| 495 | $this->assertArrayNotHasKey('review', $nodes[0]); |
| 496 | $this->assertStringNotContainsString('Josh', $encoded); |
| 497 | } |
| 498 | |
| 499 | public function test_map_booking_without_a_count_still_emits_its_reviews(): void |
| 500 | { |
| 501 | // review_score present but no review_count/total_rating → no AggregateRating |
| 502 | // (Google needs a count). The per-reviewer scores are independent of that |
| 503 | // count, so the Review nodes still carry the node. |
| 504 | $header = [['name' => 'Hotel', 'info' => ['review_score' => 8.5, 'review_score_word' => 'Great']]]; |
| 505 | $nodes = $this->service->map_feed_to_schema($header, [$this->booking_review('Al', 8.5, 'Nice')], [['provider' => 'booking']]); |
| 506 | |
| 507 | $this->assertArrayNotHasKey('aggregateRating', $nodes[0]); |
| 508 | $this->assertSame('10', $nodes[0]['review'][0]['reviewRating']['ratingValue']); |
| 509 | } |
| 510 | |
| 511 | public function test_map_booking_on_lite_emits_nothing(): void |
| 512 | { |
| 513 | // On Lite the HEADER shows no Booking rating (templates/frontend/lite/header.php |
| 514 | // emits none, and the pro one is filtered out of the Free zip). The per-card |
| 515 | // badge does still render there — it resolves via $generic_path — so this pins a |
| 516 | // deliberate under-claim, not "nothing is visible": the schema stays silent |
| 517 | // rather than asserting a rating whose surface we don't fully control on Lite. |
| 518 | // See substituted_slot_rating()'s docblock. |
| 519 | $nodes = $this->service->map_feed_to_schema( |
| 520 | $this->booking_header(8.9, 40), |
| 521 | [$this->booking_review('Max', 9.0, 'Great')], |
| 522 | [['provider' => 'booking']], |
| 523 | false |
| 524 | ); |
| 525 | $this->assertSame([], $nodes); |
| 526 | } |
| 527 | |
| 528 | public function test_map_mixed_feed_keeps_each_review_on_its_own_scale(): void |
| 529 | { |
| 530 | // Mixed feed: header is not booking-only → 5-star aggregate. Each card keeps |
| 531 | // the scale it renders in, and every Review carries its own bestRating, so a |
| 532 | // 10-scale Booking review and a 5-scale Google review coexist in one node. |
| 533 | $header = [['name' => 'Hotel', 'info' => ['rating' => 4.5, 'total_rating' => 10]]]; |
| 534 | $posts = [ |
| 535 | $this->booking_review('Bo', 9.0, 'Lovely hotel', 1600000000, 4.0), |
| 536 | $this->review('Gina', 5, 'Great'), |
| 537 | ]; |
| 538 | $nodes = $this->service->map_feed_to_schema($header, $posts, [['provider' => 'booking'], ['provider' => 'google']]); |
| 539 | $node = $nodes[0]; |
| 540 | |
| 541 | $this->assertSame('5', $node['aggregateRating']['bestRating']); |
| 542 | $this->assertCount(2, $node['review']); |
| 543 | |
| 544 | $this->assertSame('Bo', $node['review'][0]['author']['name']); |
| 545 | $this->assertSame('8', $node['review'][0]['reviewRating']['ratingValue']); |
| 546 | $this->assertSame('10', $node['review'][0]['reviewRating']['bestRating']); |
| 547 | |
| 548 | $this->assertSame('Gina', $node['review'][1]['author']['name']); |
| 549 | $this->assertSame('5', $node['review'][1]['reviewRating']['ratingValue']); |
| 550 | $this->assertSame('5', $node['review'][1]['reviewRating']['bestRating']); |
| 551 | } |
| 552 | |
| 553 | public function test_map_product_wins_over_lodging_in_mixed_feed(): void |
| 554 | { |
| 555 | // Precedence: a purchasable product source outranks a lodging source. |
| 556 | $nodes = $this->service->map_feed_to_schema( |
| 557 | $this->header(4.5, 8), |
| 558 | [$this->review('Sam', 4, 'Ok')], |
| 559 | [['provider' => 'airbnb'], ['provider' => 'aliexpress']] |
| 560 | ); |
| 561 | $this->assertSame('Product', $nodes[0]['@type']); |
| 562 | } |
| 563 | |
| 564 | public function test_map_returns_empty_without_rating_or_reviews(): void |
| 565 | { |
| 566 | // No aggregate (0/0) and no reviews → nothing to emit. |
| 567 | $this->assertSame([], $this->service->map_feed_to_schema($this->header(0, 0), [], [['provider' => 'google']])); |
| 568 | } |
| 569 | |
| 570 | public function test_standard_5star_node_carries_google_required_props(): void |
| 571 | { |
| 572 | // Structural gate for the Google Rich Results DoD on a standard 5-star node |
| 573 | // (Product/LocalBusiness): AggregateRating in range + a genuine per-review |
| 574 | // reviewRating. (Booking is a distinct contract — 0-10 aggregate, no |
| 575 | // per-review rating — covered by the Booking tests above.) |
| 576 | $node = $this->service->map_feed_to_schema( |
| 577 | $this->header(4.6, 15), |
| 578 | [$this->review('Dee', 5, 'Great')], |
| 579 | [['provider' => 'woocommerce']] |
| 580 | )[0]; |
| 581 | $this->assertArrayHasKey('@type', $node); |
| 582 | $this->assertArrayHasKey('name', $node); |
| 583 | // AggregateRating: ratingValue + reviewCount + best/worst bounds. |
| 584 | $agg = $node['aggregateRating']; |
| 585 | $this->assertSame('AggregateRating', $agg['@type']); |
| 586 | foreach (['ratingValue', 'reviewCount', 'bestRating', 'worstRating'] as $k) { |
| 587 | $this->assertArrayHasKey($k, $agg); |
| 588 | $this->assertNotSame('', (string) $agg[$k]); |
| 589 | } |
| 590 | // worstRating <= ratingValue <= bestRating (Google rejects out-of-range). |
| 591 | $this->assertGreaterThanOrEqual((float) $agg['worstRating'], (float) $agg['ratingValue']); |
| 592 | $this->assertLessThanOrEqual((float) $agg['bestRating'], (float) $agg['ratingValue']); |
| 593 | // Review: author + reviewRating.ratingValue. |
| 594 | $rev = $node['review'][0]; |
| 595 | $this->assertSame('Review', $rev['@type']); |
| 596 | $this->assertArrayHasKey('name', $rev['author']); |
| 597 | $this->assertArrayHasKey('ratingValue', $rev['reviewRating']); |
| 598 | } |
| 599 | |
| 600 | public function test_map_emits_reviews_even_without_aggregate(): void |
| 601 | { |
| 602 | // Reviews present but no usable aggregate → still emit (review-only snippet). |
| 603 | $nodes = $this->service->map_feed_to_schema($this->header(0, 0), [$this->review('Kim', 5, 'Loved it')], [['provider' => 'google']]); |
| 604 | $this->assertCount(1, $nodes); |
| 605 | $this->assertArrayNotHasKey('aggregateRating', $nodes[0]); |
| 606 | $this->assertCount(1, $nodes[0]['review']); |
| 607 | } |
| 608 | |
| 609 | public function test_map_caps_reviews_at_max(): void |
| 610 | { |
| 611 | $posts = []; |
| 612 | for ($i = 0; $i < SBR_Schema_Service::MAX_REVIEWS + 5; $i++) { |
| 613 | $posts[] = $this->review('User' . $i, 5, 'Review ' . $i); |
| 614 | } |
| 615 | $nodes = $this->service->map_feed_to_schema($this->header(4.8, 100), $posts, [['provider' => 'google']]); |
| 616 | $this->assertCount(SBR_Schema_Service::MAX_REVIEWS, $nodes[0]['review']); |
| 617 | // AggregateRating still carries the true total, not the capped sample. |
| 618 | $this->assertSame('100', $nodes[0]['aggregateRating']['reviewCount']); |
| 619 | } |
| 620 | |
| 621 | public function test_map_skips_non_array_and_empty_reviews(): void |
| 622 | { |
| 623 | $posts = ['not-an-array', $this->review('', 5, ''), $this->review('Real', 4, 'Words')]; |
| 624 | $nodes = $this->service->map_feed_to_schema($this->header(4.0, 5), $posts, [['provider' => 'google']]); |
| 625 | $this->assertCount(1, $nodes[0]['review']); |
| 626 | $this->assertSame('Real', $nodes[0]['review'][0]['author']['name']); |
| 627 | } |
| 628 | |
| 629 | // ---------- is_enabled ---------- |
| 630 | |
| 631 | public function test_is_enabled_defaults_on_when_unset(): void |
| 632 | { |
| 633 | $GLOBALS['wp_options_mock']['sbr_settings'] = []; |
| 634 | $this->assertTrue($this->service->is_enabled()); |
| 635 | } |
| 636 | |
| 637 | public function test_is_enabled_respects_off(): void |
| 638 | { |
| 639 | $GLOBALS['wp_options_mock']['sbr_settings'] = ['enableSchema' => false]; |
| 640 | $this->assertFalse($this->service->is_enabled()); |
| 641 | } |
| 642 | |
| 643 | public function test_is_enabled_filter_overrides(): void |
| 644 | { |
| 645 | $GLOBALS['wp_options_mock']['sbr_settings'] = ['enableSchema' => true]; |
| 646 | $GLOBALS['wp_filter_mock']['sbr_enable_schema'] = false; |
| 647 | $this->assertFalse($this->service->is_enabled()); |
| 648 | } |
| 649 | |
| 650 | // ---------- L3 sink: script-breakout neutralized at the shared mapper ---------- |
| 651 | |
| 652 | private const BREAKOUT_AUTHOR = 'Mallory</script><script>alert(1)</script>'; |
| 653 | private const BREAKOUT_BODY = 'nice </script> try'; |
| 654 | |
| 655 | private function breakoutNodes(): array |
| 656 | { |
| 657 | $nodes = $this->service->map_feed_to_schema( |
| 658 | $this->header(5.0, 1), |
| 659 | [$this->review(self::BREAKOUT_AUTHOR, 5, self::BREAKOUT_BODY)], |
| 660 | [['provider' => 'google']] |
| 661 | ); |
| 662 | $ref = new \ReflectionProperty(SBR_Schema_Service::class, 'nodes'); |
| 663 | $ref->setAccessible(true); |
| 664 | $ref->setValue($this->service, $nodes); |
| 665 | |
| 666 | return $nodes; |
| 667 | } |
| 668 | |
| 669 | public function test_map_preserves_review_text_without_truncation(): void |
| 670 | { |
| 671 | // Full text is kept (parity with the visible feed) — not tag-stripped, |
| 672 | // so a bare `<` in legitimate review text is never truncated. |
| 673 | $review = $this->service->map_feed_to_schema( |
| 674 | $this->header(5.0, 1), |
| 675 | [$this->review('Al <3 fans', 5, 'cheaper than < $10 elsewhere')], |
| 676 | [['provider' => 'google']] |
| 677 | )[0]['review'][0]; |
| 678 | |
| 679 | $this->assertSame('Al <3 fans', $review['author']['name']); |
| 680 | $this->assertSame('cheaper than < $10 elsewhere', $review['reviewBody']); |
| 681 | } |
| 682 | |
| 683 | public function test_map_decodes_entities_into_the_json_ld_data_sink(): void |
| 684 | { |
| 685 | // The regression this pins: SMASH-1795 removed the read-path decode in |
| 686 | // Parser::get_text()/get_reviewer_name() because their other consumers are HTML |
| 687 | // sinks, where the browser resolves a character reference. JSON-LD is not one — |
| 688 | // inside <script type="application/ld+json"> nothing resolves it — so without a |
| 689 | // decode at this boundary a Danish/German row written by one of the non-decoding |
| 690 | // writers (Woo/EDD comment_content, bulk updaters, review form) publishes |
| 691 | // "Søren" as the author's name in the rich snippet while the feed itself |
| 692 | // renders "Søren" correctly. |
| 693 | $review = $this->service->map_feed_to_schema( |
| 694 | $this->header(4.8, 12), |
| 695 | [$this->review('Søren Müller', 5, 'Bedste smørrebrød & kaffe i Café')], |
| 696 | [['provider' => 'google']] |
| 697 | )[0]['review'][0]; |
| 698 | |
| 699 | $this->assertSame('Søren Müller', $review['author']['name']); |
| 700 | $this->assertSame('Bedste smørrebrød & kaffe i Café', $review['reviewBody']); |
| 701 | } |
| 702 | |
| 703 | public function test_map_decodes_the_business_name_too(): void |
| 704 | { |
| 705 | $node = $this->service->map_feed_to_schema( |
| 706 | $this->header(4.8, 12, 'Café Nord & Co'), |
| 707 | [$this->review('Ann', 5, 'Lovely')], |
| 708 | [['provider' => 'google']] |
| 709 | )[0]; |
| 710 | |
| 711 | $this->assertSame('Café Nord & Co', $node['name']); |
| 712 | } |
| 713 | |
| 714 | public function test_map_survives_a_non_string_business_name(): void |
| 715 | { |
| 716 | // Parser::get_business_name() has no return type and its first branch returns |
| 717 | // business.name verbatim with no is_string() guard, so a cached feed can hand |
| 718 | // this method an array. Decoding it uncast would raise a PHP 8 TypeError inside |
| 719 | // wp_head — a white page instead of a missing snippet. |
| 720 | $nodes = $this->service->map_feed_to_schema( |
| 721 | [['business' => ['name' => ['unexpected' => 'array']], 'info' => ['rating' => 4.5, 'total_rating' => 3]]], |
| 722 | [$this->review('Ann', 5, 'Lovely')], |
| 723 | [['provider' => 'google']] |
| 724 | ); |
| 725 | |
| 726 | $this->assertIsArray($nodes); |
| 727 | $this->assertIsString($nodes[0]['name']); |
| 728 | // A bare (string) cast would have published the literal "Array" here; the |
| 729 | // non-scalar is rejected so the site-name fallback takes over instead. |
| 730 | $this->assertNotSame('Array', $nodes[0]['name']); |
| 731 | } |
| 732 | |
| 733 | public function test_map_decode_does_not_truncate_a_bare_angle_bracket(): void |
| 734 | { |
| 735 | // Guards the fix against its own cure: wp_strip_all_tags() would have been the |
| 736 | // obvious companion to the decode, and it eats everything after an unclosed '<'. |
| 737 | // Parity with the visible feed matters more here than tidy markup. |
| 738 | $review = $this->service->map_feed_to_schema( |
| 739 | $this->header(5.0, 1), |
| 740 | [$this->review('Al <3 fans', 5, 'cheaper than < $10 elsewhere')], |
| 741 | [['provider' => 'google']] |
| 742 | )[0]['review'][0]; |
| 743 | |
| 744 | $this->assertSame('Al <3 fans', $review['author']['name']); |
| 745 | $this->assertSame('cheaper than < $10 elsewhere', $review['reviewBody']); |
| 746 | } |
| 747 | |
| 748 | public function test_print_json_ld_path_b_hex_escapes_breakout(): void |
| 749 | { |
| 750 | $this->breakoutNodes(); |
| 751 | |
| 752 | ob_start(); |
| 753 | $this->service->print_json_ld(); |
| 754 | $out = ob_get_clean(); |
| 755 | |
| 756 | // Only the wrapper's own tag pair; the adversarial </script> in the data |
| 757 | // is hex-escaped (JSON_HEX_TAG), not emitted literally. |
| 758 | $this->assertSame(1, substr_count($out, '<script type="application/ld+json"')); |
| 759 | $this->assertSame(1, substr_count($out, '</script>')); |
| 760 | $this->assertStringNotContainsString('</script><script>alert(1)', $out); |
| 761 | $this->assertStringContainsString('<', $out); // JSON_HEX_TAG escaped the data's `<` |
| 762 | } |
| 763 | |
| 764 | public function test_merge_into_aioseo_neutralizes_the_smart_tag_trigger(): void |
| 765 | { |
| 766 | // AIOSEO resolves #<tag> in every graph string, AFTER this filter returns and |
| 767 | // after its own sanitising: Schema/Helpers.php:82 (our filter) -> :83 |
| 768 | // cleanAndParseData -> :54 strip -> :57 replaceTags. #custom_field-<key> resolves |
| 769 | // post meta (Utils/Tags.php:1360, :1448) and #featured_image returns a raw <img> |
| 770 | // (:1090), so an unauthenticated reviewer could have post meta printed in <head>. |
| 771 | // We drop the trigger rather than enumerating ~70 tag ids. |
| 772 | $nodes = $this->service->map_feed_to_schema( |
| 773 | [[ |
| 774 | 'name' => 'Acme #custom_field-_owner_email', |
| 775 | 'info' => [ |
| 776 | 'rating' => 5.0, 'total_rating' => 1, |
| 777 | 'url' => 'https://shop.test/p#custom_field-_secret', |
| 778 | 'image' => 'https://shop.test/i.png#description', |
| 779 | ], |
| 780 | ]], |
| 781 | [$this->review('Mallory #author_name', 5, 'see ##custom_field-_secret and ###featured_image')], |
| 782 | [['provider' => 'google']] |
| 783 | ); |
| 784 | $ref = new \ReflectionProperty(SBR_Schema_Service::class, 'nodes'); |
| 785 | $ref->setAccessible(true); |
| 786 | $ref->setValue($this->service, $nodes); |
| 787 | |
| 788 | $graph = $this->service->merge_into_aioseo([['@type' => 'WebPage']]); |
| 789 | $node = $graph[1]; |
| 790 | $review = $node['review'][0]; |
| 791 | |
| 792 | // No trigger left anywhere — doubled and tripled included, since there is no |
| 793 | // pattern to bypass. |
| 794 | foreach ([$node['name'], $node['url'], $node['image'], $review['author']['name'], $review['reviewBody']] as $v) { |
| 795 | $this->assertStringNotContainsString('#', $v, "trigger survived in: $v"); |
| 796 | } |
| 797 | // Free text keeps its words. URLs are truncated at the fragment, which still |
| 798 | // addresses the same resource — encoding the '#' as %23 would NOT: per RFC 3986 |
| 799 | // that is a literal '#' in the path, so the URL would 404. |
| 800 | $this->assertStringContainsString('custom_field-_secret', $review['reviewBody']); |
| 801 | $this->assertSame('https://shop.test/p', $node['url']); |
| 802 | $this->assertSame('https://shop.test/i.png', $node['image']); |
| 803 | } |
| 804 | |
| 805 | /** |
| 806 | * @dataProvider urlKeyCases |
| 807 | */ |
| 808 | public function test_url_keyed_values_are_truncated_at_the_fragment(string $in, string $expected): void |
| 809 | { |
| 810 | $m = new \ReflectionMethod(SBR_Schema_Service::class, 'neutralize_smart_tags'); |
| 811 | $m->setAccessible(true); |
| 812 | |
| 813 | $this->assertSame($expected, $m->invoke($this->service, $in, 'url')); |
| 814 | } |
| 815 | |
| 816 | public static function urlKeyCases(): array |
| 817 | { |
| 818 | return [ |
| 819 | 'fragment after a path' => ['https://x.test/p#REVIEWS', 'https://x.test/p'], |
| 820 | 'google lrd fragment' => ['https://maps.test/x#lrd=0xabc', 'https://maps.test/x'], |
| 821 | 'no fragment' => ['https://x.test/p', 'https://x.test/p'], |
| 822 | // Not URLs at all — emit nothing rather than a bogus relative URL, and never |
| 823 | // a live tag. AIOSEO drops empty graph values. |
| 824 | 'fragment only, tag' => ['#post_title', ''], |
| 825 | 'fragment only, bare' => ['#', ''], |
| 826 | 'doubled trigger only' => ['##custom_field-_secret', ''], |
| 827 | ]; |
| 828 | } |
| 829 | |
| 830 | public function test_merge_into_aioseo_path_a_no_raw_angle_bracket(): void |
| 831 | { |
| 832 | // Breakout payloads span all vectors: entity NAME (`</script`), review |
| 833 | // author (`<!--<script` double-escape), and a legit `< $10` in the body. |
| 834 | $nodes = $this->service->map_feed_to_schema( |
| 835 | $this->header(5.0, 1, 'Acme</script><script>alert(1)</script>'), |
| 836 | [$this->review('Mallory<!--<script>', 5, 'cheaper than < $10')], |
| 837 | [['provider' => 'google']] |
| 838 | ); |
| 839 | $ref = new \ReflectionProperty(SBR_Schema_Service::class, 'nodes'); |
| 840 | $ref->setAccessible(true); |
| 841 | $ref->setValue($this->service, $nodes); |
| 842 | |
| 843 | $graph = $this->service->merge_into_aioseo([['@type' => 'WebPage']]); |
| 844 | |
| 845 | // Structural: our node is appended and its keys survive array_map. |
| 846 | $this->assertSame('LocalBusiness', $graph[1]['@type']); |
| 847 | |
| 848 | // Encode as AIOSEO's worst case: unescaped slashes, NO JSON_HEX_TAG |
| 849 | // (Schema/Helpers.php:106). No raw `<` may reach the <script> context — |
| 850 | // this covers </script, <script and <!--<script at once. |
| 851 | $json = json_encode($graph, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); |
| 852 | $this->assertStringNotContainsString('<', $json); |
| 853 | // Legit text is preserved, just entity-escaped (recoverable, not truncated). |
| 854 | $this->assertStringContainsString('< $10', $json); |
| 855 | $this->assertStringContainsString('Acme<', $json); |
| 856 | } |
| 857 | } |
| 858 |