PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.6.7
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.6.7
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 / MultiSourceAggregationTest.php
reviews-feed / tests / Unit Last commit date
Doubles 2 months ago Providers 2 months ago BulkReviewsUpdateStuckStateTest.php 2 months ago ClearCacheRelayResetTest.php 2 months ago DeleteSourceRelayFailureTest.php 2 months ago ErrorHandlerFalsyOptionTest.php 2 months ago FeedCacheUpdateServiceTest.php 2 months ago FeedMalformedPayloadTest.php 2 months ago ForceKeylessRefetchTest.php 2 months ago LicenseDeactivateStaleStateTest.php 2 months ago MediaFinderMemoTest.php 2 months ago MultiSourceAggregationTest.php 2 months ago ReconcileMigratedLicenseRoutineTest.php 2 months ago ReconcileRemovalTest.php 2 months ago RegisterWebsiteRoutineTest.php 2 months ago RemoteRequestMemoTest.php 2 months ago ReviewAlertHeaderTotalsTest.php 2 months ago ReviewAlertPageTargetingTest.php 2 months ago ReviewAlertStarFillTest.php 2 months ago ShortcodeNeutralizationTest.php 2 months ago SiteMigrationRecoveryTest.php 2 months ago Smash1583HeaderParityTest.php 2 months ago SourceIdLookupTest.php 2 months ago WpmlGetCurrentLanguageTest.php 2 months ago WpmlLanguageMappingTest.php 2 months ago
MultiSourceAggregationTest.php
382 lines
1 <?php
2
3 namespace SmashBalloon\Reviews\Tests\Unit;
4
5 use PHPUnit\Framework\TestCase;
6 use SmashBalloon\Reviews\Common\Parser;
7
8 /**
9 * SMASH-1583 — multi-source feed header aggregation.
10 *
11 * Covers the combined review count + weighted average rating shown in the
12 * feed header when a feed mixes sources from more than one provider.
13 *
14 * Two stacked bugs are exercised here:
15 *
16 * Bug 1 — Facebook persists `total_rating: 0` (legacy `rating_count` is
17 * deprecated for recommendation Pages), so the source contributed 0
18 * to the combined count even though its reviews are cached. Fixed by
19 * Parser::backfill_review_counts(), which fills an empty count from
20 * the actual cached reviews keyed by source.id.
21 *
22 * Bug 2 — Parser::get_average_rating() averaged source ratings as equal
23 * peers, so 3 reviews @5.0 swung the headline as hard as 219 @4.7.
24 * Fixed by weighting each source's rating by its review count.
25 *
26 * The matrix deliberately goes beyond the reported Google+Facebook pair:
27 * Google+Yelp+Trustpilot, single-source, equal-count, all-zero-count (BC
28 * fallback), WooCommerce keys, and the SMASH-1412 feed_aggregated short-circuit.
29 *
30 * Assertions run through the public aggregation API (get_average_rating /
31 * get_num_ratings) on backfilled header data, which is exactly what the feed
32 * header template does at render time.
33 */
34 class MultiSourceAggregationTest extends TestCase
35 {
36 private function parser(): Parser
37 {
38 return new Parser();
39 }
40
41 /**
42 * Build a header-data source entry.
43 */
44 private function source(array $info): array
45 {
46 return ['info' => $info];
47 }
48
49 /**
50 * Build N normalized cached review posts for a given source id.
51 */
52 private function posts(string $source_id, int $n): array
53 {
54 $out = [];
55 for ($i = 0; $i < $n; $i++) {
56 $out[] = [
57 'source' => ['id' => $source_id, 'url' => ''],
58 'rating' => 5,
59 'reviewer' => ['name' => 'Reviewer ' . $i],
60 'provider' => ['name' => 'facebook'],
61 ];
62 }
63 return $out;
64 }
65
66 /*
67 |--------------------------------------------------------------------------
68 | The reported scenario: Google (219 @ 4.7) + Facebook (3 @ 5.0)
69 |--------------------------------------------------------------------------
70 */
71
72 public function test_google_plus_facebook_reports_weighted_average_after_backfill(): void
73 {
74 $header = [
75 $this->source(['id' => 'GOOG', 'rating' => 4.7, 'total_rating' => 219]),
76 $this->source(['id' => 'FB1', 'rating' => 5.0, 'total_rating' => 0]),
77 ];
78 // Cache holds all 3 FB recommendations + a capped page of Google reviews.
79 $posts = array_merge($this->posts('FB1', 3), $this->posts('GOOG', 20));
80
81 $header = $this->parser()->backfill_review_counts($header, $posts);
82
83 // Average is the honest weighted mean, not round((4.7+5.0)/2,1)=4.9.
84 $this->assertSame(4.7, $this->parser()->get_average_rating($header));
85 // Combined count is 219 + 3 = 222 (NOT 219, and NOT 219 + 20 cached).
86 $this->assertSame(222, $this->parser()->get_num_ratings($header));
87 }
88
89 public function test_average_is_already_correct_even_if_facebook_count_stays_zero(): void
90 {
91 // Without backfill the weighted mean drops the 0-count FB source, which
92 // still yields the correct 4.7 (Google only). The count is the part that
93 // needs the backfill, not the average.
94 $header = [
95 $this->source(['id' => 'GOOG', 'rating' => 4.7, 'total_rating' => 219]),
96 $this->source(['id' => 'FB1', 'rating' => 5.0, 'total_rating' => 0]),
97 ];
98 $this->assertSame(4.7, $this->parser()->get_average_rating($header));
99 $this->assertSame(219, $this->parser()->get_num_ratings($header));
100 }
101
102 /*
103 |--------------------------------------------------------------------------
104 | backfill_review_counts() behaviour (asserted via the public count API)
105 |--------------------------------------------------------------------------
106 */
107
108 public function test_backfill_fills_empty_count_from_cached_reviews(): void
109 {
110 $header = [$this->source(['id' => 'FB1', 'rating' => 5.0, 'total_rating' => 0])];
111 $out = $this->parser()->backfill_review_counts($header, $this->posts('FB1', 3));
112 $this->assertSame(3, $this->parser()->get_num_ratings($out));
113 }
114
115 public function test_backfill_does_not_lower_a_higher_reported_count(): void
116 {
117 // Google reports 219 but only 20 are cached — counting cached posts
118 // would UNDER-report it, so max(219, 20) must keep 219.
119 $header = [$this->source(['id' => 'GOOG', 'rating' => 4.7, 'total_rating' => 219])];
120 $out = $this->parser()->backfill_review_counts($header, $this->posts('GOOG', 20));
121 $this->assertSame(219, $this->parser()->get_num_ratings($out));
122 }
123
124 public function test_backfill_corrects_a_stale_low_reported_count(): void
125 {
126 // SMASH-1583 live case (InstaQA): Facebook stored a stale total_rating: 1
127 // (deprecated rating_count) but 2 recommendations are cached and shown.
128 // max(1, 2) must correct the header up to 2, not leave it at 1.
129 $header = [$this->source(['id' => 'FB1', 'rating' => 5.0, 'total_rating' => 1])];
130 $out = $this->parser()->backfill_review_counts($header, $this->posts('FB1', 2));
131 $this->assertSame(2, $this->parser()->get_num_ratings($out));
132 }
133
134 public function test_backfill_respects_review_count_key_and_does_not_override(): void
135 {
136 // WooCommerce-style source reports `review_count`, not `total_rating`.
137 // get_num_ratings reads total_rating ?? review_count, so a correct
138 // backfill (no override) yields 100, while a wrong one would yield 5.
139 $header = [$this->source(['id' => 'WOO', 'rating' => 4.0, 'review_count' => 100])];
140 $out = $this->parser()->backfill_review_counts($header, $this->posts('WOO', 5));
141 $this->assertSame(100, $this->parser()->get_num_ratings($out));
142 }
143
144 public function test_backfill_is_noop_with_no_posts(): void
145 {
146 $header = [$this->source(['id' => 'FB1', 'rating' => 5.0, 'total_rating' => 0])];
147 $this->assertSame($header, $this->parser()->backfill_review_counts($header, []));
148 }
149
150 public function test_backfill_skips_posts_without_source_id(): void
151 {
152 $header = [$this->source(['id' => 'FB1', 'rating' => 5.0, 'total_rating' => 0])];
153 $posts = [
154 ['rating' => 5, 'provider' => ['name' => 'facebook']], // no source key
155 ['source' => ['id' => ''], 'rating' => 5], // empty source id
156 ['source' => 'not-an-array', 'rating' => 5], // malformed source
157 ];
158 $out = $this->parser()->backfill_review_counts($header, $posts);
159 // Nothing matched FB1 → count stays 0.
160 $this->assertSame(0, $this->parser()->get_num_ratings($out));
161 }
162
163 /*
164 |--------------------------------------------------------------------------
165 | get_average_rating() — weighting across more provider combinations
166 |--------------------------------------------------------------------------
167 */
168
169 public function test_weighted_average_diverges_from_unweighted_when_volumes_differ(): void
170 {
171 // 100 @ 4.0 + 1 @ 5.0 → weighted (400+5)/101 = 4.0 (unweighted would be 4.5).
172 $header = [
173 $this->source(['id' => 'A', 'rating' => 4.0, 'total_rating' => 100]),
174 $this->source(['id' => 'B', 'rating' => 5.0, 'total_rating' => 1]),
175 ];
176 $this->assertSame(4.0, $this->parser()->get_average_rating($header));
177 }
178
179 public function test_weighted_equals_unweighted_when_volumes_match(): void
180 {
181 $header = [
182 $this->source(['id' => 'A', 'rating' => 4.0, 'total_rating' => 100]),
183 $this->source(['id' => 'B', 'rating' => 5.0, 'total_rating' => 100]),
184 ];
185 $this->assertSame(4.5, $this->parser()->get_average_rating($header));
186 }
187
188 public function test_three_source_google_yelp_trustpilot_weighted(): void
189 {
190 // (4.7*219 + 4.0*50 + 3.0*10) / 279 = 1259.3/279 = 4.51… → 4.5
191 $header = [
192 $this->source(['id' => 'GOOG', 'rating' => 4.7, 'total_rating' => 219]),
193 $this->source(['id' => 'YELP', 'rating' => 4.0, 'total_rating' => 50]),
194 $this->source(['id' => 'TRIP', 'rating' => 3.0, 'total_rating' => 10]),
195 ];
196 $this->assertSame(4.5, $this->parser()->get_average_rating($header));
197 $this->assertSame(279, $this->parser()->get_num_ratings($header));
198 }
199
200 public function test_woocommerce_average_rating_key_is_weighted_by_review_count(): void
201 {
202 // Woo sources expose `average_rating` + `review_count` (not rating/total_rating).
203 // (4.0*100 + 5.0*1)/101 = 405/101 = 4.0099 → 4.0
204 $header = [
205 $this->source(['id' => 'W1', 'average_rating' => 4.0, 'review_count' => 100]),
206 $this->source(['id' => 'W2', 'average_rating' => 5.0, 'review_count' => 1]),
207 ];
208 $this->assertSame(4.0, $this->parser()->get_average_rating($header));
209 $this->assertSame(101, $this->parser()->get_num_ratings($header));
210 }
211
212 public function test_single_source_returns_its_own_rating_and_count(): void
213 {
214 $header = [$this->source(['id' => 'GOOG', 'rating' => 4.7, 'total_rating' => 219])];
215 $this->assertSame(4.7, $this->parser()->get_average_rating($header));
216 $this->assertSame(219, $this->parser()->get_num_ratings($header));
217 }
218
219 /*
220 |--------------------------------------------------------------------------
221 | Backwards-compatibility fallbacks
222 |--------------------------------------------------------------------------
223 */
224
225 public function test_all_zero_counts_falls_back_to_unweighted_mean(): void
226 {
227 // No source reports a usable count and nothing is backfilled → preserve
228 // the legacy unweighted mean instead of dividing by zero / showing 0.0.
229 $header = [
230 $this->source(['id' => 'A', 'rating' => 4.0, 'total_rating' => 0]),
231 $this->source(['id' => 'B', 'rating' => 5.0, 'total_rating' => 0]),
232 ];
233 $this->assertSame(4.5, $this->parser()->get_average_rating($header));
234 }
235
236 public function test_feed_aggregated_short_circuit_is_preserved(): void
237 {
238 // SMASH-1412 EDD/Woo dedup path must still win and is untouched by 1583.
239 $header = [
240 $this->source([
241 'id' => 'EDD1',
242 'rating' => 4.9,
243 'total_rating' => 999,
244 'feed_aggregated' => true,
245 'feed_average_rating' => 4.3,
246 'feed_total_review_count' => 500,
247 ]),
248 $this->source(['id' => 'EDD2', 'rating' => 1.0, 'total_rating' => 1]),
249 ];
250 $this->assertSame(4.3, $this->parser()->get_average_rating($header));
251 $this->assertSame(500, $this->parser()->get_num_ratings($header));
252 }
253
254 public function test_non_array_input_returns_empty_string(): void
255 {
256 $this->assertSame('', $this->parser()->get_average_rating('nope'));
257 $this->assertSame('', $this->parser()->get_num_ratings(null));
258 }
259
260 public function test_empty_feed_does_not_divide_by_zero(): void
261 {
262 $this->assertSame(0.0, $this->parser()->get_average_rating([]));
263 $this->assertSame(0, $this->parser()->get_num_ratings([]));
264 }
265
266 /*
267 |--------------------------------------------------------------------------
268 | Exhaustive 2-by-2 provider matrix
269 |--------------------------------------------------------------------------
270 |
271 | Every unordered pair of the real provider key-shapes. Aggregation is
272 | provider-agnostic (it reads rating ?? average_rating and total_rating ??
273 | review_count), so this proves the combined count + weighted average are
274 | correct for Google+Yelp, Yelp+Trustpilot, Woo+EDD, WP.org+TripAdvisor,
275 | Facebook+anything (count backfilled from cache), etc. — not just the one
276 | pair in the ticket. Facebook is left to live QA; here it stands in for any
277 | provider that reports no count and is backfilled from cached reviews.
278 |
279 | Expected count + average are computed independently from the profile
280 | inputs (straight Σrating×count / Σcount), so a regression back to the
281 | unweighted mean — or a dropped source — fails the assertion.
282 */
283
284 /**
285 * Provider profiles keyed by name. `cached` = reviews present in the feed
286 * cache (only meaningful for providers that report no count, e.g. Facebook
287 * recommendations whose total_rating persists as 0).
288 *
289 * @return array<string,array{info:array<string,mixed>,cached:int}>
290 */
291 private static function providerProfiles(): array
292 {
293 return [
294 'google' => ['info' => ['id' => 'google', 'rating' => 4.7, 'total_rating' => 219], 'cached' => 0],
295 'yelp' => ['info' => ['id' => 'yelp', 'rating' => 4.0, 'total_rating' => 88], 'cached' => 0],
296 'trustpilot' => ['info' => ['id' => 'trustpilot', 'rating' => 3.5, 'total_rating' => 1240], 'cached' => 0],
297 'tripadvisor' => ['info' => ['id' => 'tripadvisor', 'rating' => 4.2, 'total_rating' => 51], 'cached' => 0],
298 'woocommerce' => ['info' => ['id' => 'woocommerce', 'average_rating' => 4.6, 'review_count' => 37], 'cached' => 0],
299 'edd' => ['info' => ['id' => 'edd', 'average_rating' => 4.9, 'review_count' => 12], 'cached' => 0],
300 'wordpressorg' => ['info' => ['id' => 'wordpressorg', 'rating' => 4.8, 'total_rating' => 300], 'cached' => 0],
301 'facebook' => ['info' => ['id' => 'facebook', 'rating' => 5.0, 'total_rating' => 0], 'cached' => 3],
302 ];
303 }
304
305 private static function effectiveRating(array $info): float
306 {
307 return (float) ($info['rating'] ?? $info['average_rating'] ?? 0);
308 }
309
310 private static function effectiveCount(array $profile): int
311 {
312 $info = $profile['info'];
313 $reported = (int) ($info['total_rating'] ?? $info['review_count'] ?? 0);
314 return $reported > 0 ? $reported : (int) $profile['cached'];
315 }
316
317 /**
318 * One case per unordered provider pair.
319 *
320 * @return array<string,array{0:string,1:array,2:array,3:int,4:float}>
321 */
322 public static function providerPairProvider(): array
323 {
324 $profiles = self::providerProfiles();
325 $names = array_keys($profiles);
326 $cases = [];
327
328 for ($i = 0; $i < count($names); $i++) {
329 for ($j = $i + 1; $j < count($names); $j++) {
330 $a = $profiles[$names[$i]];
331 $b = $profiles[$names[$j]];
332
333 $header = [['info' => $a['info']], ['info' => $b['info']]];
334
335 // Synthesize cached reviews for any profile that relies on backfill.
336 $posts = [];
337 foreach ([$a, $b] as $p) {
338 for ($k = 0; $k < (int) $p['cached']; $k++) {
339 $posts[] = ['source' => ['id' => $p['info']['id']], 'rating' => 5];
340 }
341 }
342
343 $ecA = self::effectiveCount($a);
344 $ecB = self::effectiveCount($b);
345 $expectedCount = $ecA + $ecB;
346 $expectedAvg = $expectedCount > 0
347 ? round((self::effectiveRating($a['info']) * $ecA + self::effectiveRating($b['info']) * $ecB) / $expectedCount, 1)
348 : 0.0;
349
350 $label = $names[$i] . '+' . $names[$j];
351 $cases[$label] = [$label, $header, $posts, $expectedCount, $expectedAvg];
352 }
353 }
354 return $cases;
355 }
356
357 /**
358 * @dataProvider providerPairProvider
359 */
360 public function test_every_provider_pair_reports_weighted_average_and_summed_count(
361 string $label,
362 array $header,
363 array $posts,
364 int $expectedCount,
365 float $expectedAvg
366 ): void {
367 $header = $this->parser()->backfill_review_counts($header, $posts);
368
369 $this->assertSame(
370 $expectedCount,
371 $this->parser()->get_num_ratings($header),
372 "Combined count wrong for {$label}"
373 );
374 $this->assertEqualsWithDelta(
375 $expectedAvg,
376 $this->parser()->get_average_rating($header),
377 0.001,
378 "Weighted average wrong for {$label}"
379 );
380 }
381 }
382