PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.11.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / tests / Unit / Smash1756SchemaServiceTest.php
reviews-feed / tests / Unit Last commit date
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&oslash;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&oslash;ren M&uuml;ller', 5, 'Bedste sm&oslash;rrebr&oslash;d &amp; kaffe i Caf&eacute;')],
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&eacute; Nord &amp; 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('&lt; $10', $json);
855 $this->assertStringContainsString('Acme&lt;', $json);
856 }
857 }
858