Doubles
1 month ago
Providers
1 month ago
BulkRearmOnGrowthTest.php
1 month ago
BulkReviewsUpdateStuckStateTest.php
1 month ago
ClearCacheRelayResetTest.php
1 month ago
DeleteSourceRelayFailureTest.php
1 month ago
ErrorHandlerFalsyOptionTest.php
1 month ago
FeedCacheUpdateServiceTest.php
1 month ago
FeedMalformedPayloadTest.php
1 month ago
ForceKeylessRefetchTest.php
1 month ago
LicenseDeactivateStaleStateTest.php
1 month ago
MediaFinderMemoTest.php
1 month ago
MultiSourceAggregationTest.php
1 month ago
ReconcileMigratedLicenseRoutineTest.php
1 month ago
ReconcileRemovalTest.php
1 month ago
RegisterWebsiteRoutineTest.php
1 month ago
RemoteRequestMemoTest.php
1 month ago
ReviewAlertHeaderTotalsTest.php
1 month ago
ReviewAlertPageTargetingTest.php
1 month ago
ReviewAlertStarFillTest.php
1 month ago
ShortcodeNeutralizationTest.php
1 month ago
SiteMigrationRecoveryTest.php
1 month ago
Smash1583HeaderParityTest.php
1 month ago
Smash1631MultiLanguageBulkTest.php
1 month ago
Smash1631UpdateSingleLangScopeTest.php
1 month ago
Smash782BookingHeaderRatingTest.php
1 month ago
Smash782CountryFlagEmojiTest.php
1 month ago
Smash782ExternalRefreshCronTest.php
1 month ago
Smash782ExtrasTemplateTest.php
1 month ago
Smash782ReviewAlertProviderDataTest.php
1 month ago
SourceIdLookupTest.php
1 month ago
WpmlGetCurrentLanguageTest.php
1 month ago
WpmlLanguageMappingTest.php
1 month ago
ReconcileMigratedLicenseRoutineTest.php
233 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SmashBalloon\Reviews\Tests\Unit; |
| 4 | |
| 5 | use PHPUnit\Framework\TestCase; |
| 6 | use SmashBalloon\Reviews\Common\Services\Upgrade\Routines\ReconcileMigratedLicenseRoutine; |
| 7 | |
| 8 | /** |
| 9 | * SMASH-1585 — one-shot recovery for sites wedged by a pre-2.6.3 migration fork. |
| 10 | * |
| 11 | * Verifies the routine: (1) runs once, only for installs that think they are |
| 12 | * licensed; (2) treats only an explicit relay license_active:false (with a |
| 13 | * healthy pong) as the wedge; (3) on a confirmed wedge flips local state to |
| 14 | * surface Activate while preserving license_key; (4) never clears a healthy or |
| 15 | * unverifiable license; (5) is loop-safe (marks done before any work). |
| 16 | */ |
| 17 | class ReconcileMigratedLicenseRoutineTest extends TestCase |
| 18 | { |
| 19 | protected function setUp(): void |
| 20 | { |
| 21 | parent::setUp(); |
| 22 | global $wp_options_mock, $wp_home_url_mock, $wp_transients_mock; |
| 23 | $wp_options_mock = []; |
| 24 | $wp_home_url_mock = 'https://example.com'; |
| 25 | $wp_transients_mock = []; |
| 26 | } |
| 27 | |
| 28 | private function invokeWillRun(ReconcileMigratedLicenseRoutine $r): bool |
| 29 | { |
| 30 | $m = new \ReflectionMethod($r, 'will_run'); |
| 31 | $m->setAccessible(true); |
| 32 | |
| 33 | return (bool) $m->invoke($r); |
| 34 | } |
| 35 | |
| 36 | private function invokeIsWedged(ReconcileMigratedLicenseRoutine $r, $ping): bool |
| 37 | { |
| 38 | $m = new \ReflectionMethod($r, 'is_wedged'); |
| 39 | $m->setAccessible(true); |
| 40 | |
| 41 | return (bool) $m->invoke($r, $ping); |
| 42 | } |
| 43 | |
| 44 | /** Routine with a stubbed ping so run() can be tested without HTTP. */ |
| 45 | private function routineWithPing($ping): ReconcileMigratedLicenseRoutine |
| 46 | { |
| 47 | return new class ($ping) extends ReconcileMigratedLicenseRoutine { |
| 48 | private $stub; |
| 49 | |
| 50 | public function __construct($stub) |
| 51 | { |
| 52 | $this->stub = $stub; |
| 53 | } |
| 54 | |
| 55 | protected function probe_ping() |
| 56 | { |
| 57 | return $this->stub; |
| 58 | } |
| 59 | }; |
| 60 | } |
| 61 | |
| 62 | private function licensedSettings(): array |
| 63 | { |
| 64 | return [ |
| 65 | 'access_token' => 'tok-forked', |
| 66 | 'license_key' => 'ABC-123', |
| 67 | 'license_status' => 'valid', |
| 68 | 'license_info' => ['license' => 'valid'], |
| 69 | 'website_url' => 'https://example.com', |
| 70 | ]; |
| 71 | } |
| 72 | |
| 73 | // ---- will_run ---------------------------------------------------------- |
| 74 | |
| 75 | public function test_will_run_true_for_licensed_install_not_yet_reconciled(): void |
| 76 | { |
| 77 | global $wp_options_mock; |
| 78 | $wp_options_mock['sbr_settings'] = $this->licensedSettings(); |
| 79 | |
| 80 | $this->assertTrue($this->invokeWillRun(new ReconcileMigratedLicenseRoutine())); |
| 81 | } |
| 82 | |
| 83 | public function test_will_run_false_once_done_flag_set(): void |
| 84 | { |
| 85 | global $wp_options_mock; |
| 86 | $wp_options_mock['sbr_settings'] = $this->licensedSettings(); |
| 87 | $wp_options_mock['sbr_license_reconcile_1585_done'] = time(); |
| 88 | |
| 89 | $this->assertFalse($this->invokeWillRun(new ReconcileMigratedLicenseRoutine())); |
| 90 | } |
| 91 | |
| 92 | public function test_will_run_false_when_status_not_valid(): void |
| 93 | { |
| 94 | global $wp_options_mock; |
| 95 | $s = $this->licensedSettings(); |
| 96 | $s['license_status'] = ''; |
| 97 | $wp_options_mock['sbr_settings'] = $s; |
| 98 | |
| 99 | $this->assertFalse($this->invokeWillRun(new ReconcileMigratedLicenseRoutine())); |
| 100 | } |
| 101 | |
| 102 | public function test_will_run_false_without_key_or_token(): void |
| 103 | { |
| 104 | global $wp_options_mock; |
| 105 | $noKey = $this->licensedSettings(); |
| 106 | $noKey['license_key'] = ''; |
| 107 | $wp_options_mock['sbr_settings'] = $noKey; |
| 108 | $this->assertFalse($this->invokeWillRun(new ReconcileMigratedLicenseRoutine())); |
| 109 | |
| 110 | $noToken = $this->licensedSettings(); |
| 111 | $noToken['access_token'] = ''; |
| 112 | $wp_options_mock['sbr_settings'] = $noToken; |
| 113 | $this->assertFalse($this->invokeWillRun(new ReconcileMigratedLicenseRoutine())); |
| 114 | } |
| 115 | |
| 116 | // ---- is_wedged --------------------------------------------------------- |
| 117 | |
| 118 | public function test_is_wedged_true_only_for_explicit_license_inactive_with_pong(): void |
| 119 | { |
| 120 | $r = new ReconcileMigratedLicenseRoutine(); |
| 121 | $this->assertTrue($this->invokeIsWedged($r, [ |
| 122 | 'success' => true, 'pong' => true, 'license_active' => false, |
| 123 | ])); |
| 124 | } |
| 125 | |
| 126 | public function test_is_wedged_false_for_healthy_or_unverifiable_signals(): void |
| 127 | { |
| 128 | $r = new ReconcileMigratedLicenseRoutine(); |
| 129 | // Healthy. |
| 130 | $this->assertFalse($this->invokeIsWedged($r, ['success' => true, 'pong' => true, 'license_active' => true])); |
| 131 | // Flag missing (older relay) — must not clear. |
| 132 | $this->assertFalse($this->invokeIsWedged($r, ['success' => true, 'pong' => true])); |
| 133 | // Not reachable / no pong. |
| 134 | $this->assertFalse($this->invokeIsWedged($r, ['success' => true, 'license_active' => false])); |
| 135 | // Error / unreachable. |
| 136 | $this->assertFalse($this->invokeIsWedged($r, null)); |
| 137 | $this->assertFalse($this->invokeIsWedged($r, ['success' => false, 'license_active' => false])); |
| 138 | // Dead/invalid token after migration — relay rejects it, so the call |
| 139 | // returns an error envelope (success false, no pong). Must NOT be treated |
| 140 | // as a wedge (the register/reverify path handles a dead token, not this). |
| 141 | $this->assertFalse($this->invokeIsWedged($r, ['success' => false, 'apiMessage' => 'invalid token'])); |
| 142 | // Malformed relay response: license_active present but null (not a strict |
| 143 | // false) — must not clear on an ambiguous value. |
| 144 | $this->assertFalse($this->invokeIsWedged($r, ['success' => true, 'pong' => true, 'license_active' => null])); |
| 145 | // Garbage / wrong-type payloads must not throw or clear. |
| 146 | $this->assertFalse($this->invokeIsWedged($r, 'unexpected-string')); |
| 147 | $this->assertFalse($this->invokeIsWedged($r, ['success' => true, 'pong' => true, 'license_active' => 'false'])); |
| 148 | } |
| 149 | |
| 150 | // ---- run --------------------------------------------------------------- |
| 151 | |
| 152 | public function test_run_flips_to_activate_on_confirmed_wedge_preserving_key(): void |
| 153 | { |
| 154 | global $wp_options_mock; |
| 155 | $wp_options_mock['sbr_settings'] = $this->licensedSettings(); |
| 156 | $wp_options_mock['sbr_statuses'] = ['license_tier' => 3, 'license_info' => 'Pro Elite']; |
| 157 | |
| 158 | $routine = $this->routineWithPing(['success' => true, 'pong' => true, 'license_active' => false]); |
| 159 | $routine->run(); |
| 160 | |
| 161 | $s = $wp_options_mock['sbr_settings']; |
| 162 | $this->assertSame('', $s['license_status'], 'license_status cleared -> panel surfaces Activate'); |
| 163 | $this->assertSame('', $s['license_info']); |
| 164 | $this->assertSame('ABC-123', $s['license_key'], 'license_key preserved for one-click re-link'); |
| 165 | $this->assertNotEmpty($wp_options_mock['sbr_license_reconcile_1585_done'], 'done flag set'); |
| 166 | // #482 review: tier must also be cleared in sbr_statuses, else feature |
| 167 | // gating keeps treating the install as paid while Activate is surfaced. |
| 168 | $this->assertSame(0, $wp_options_mock['sbr_statuses']['license_tier'], 'license_tier reset so gating drops to free'); |
| 169 | } |
| 170 | |
| 171 | public function test_run_leaves_healthy_license_untouched(): void |
| 172 | { |
| 173 | global $wp_options_mock; |
| 174 | $wp_options_mock['sbr_settings'] = $this->licensedSettings(); |
| 175 | |
| 176 | $routine = $this->routineWithPing(['success' => true, 'pong' => true, 'license_active' => true]); |
| 177 | $routine->run(); |
| 178 | |
| 179 | $this->assertSame('valid', $wp_options_mock['sbr_settings']['license_status'], 'healthy license untouched'); |
| 180 | $this->assertNotEmpty($wp_options_mock['sbr_license_reconcile_1585_done'], 'still one-shot: done flag set'); |
| 181 | } |
| 182 | |
| 183 | public function test_run_does_nothing_on_inconclusive_ping_but_still_marks_done(): void |
| 184 | { |
| 185 | global $wp_options_mock; |
| 186 | $wp_options_mock['sbr_settings'] = $this->licensedSettings(); |
| 187 | |
| 188 | $routine = $this->routineWithPing(null); // unreachable / error |
| 189 | $routine->run(); |
| 190 | |
| 191 | $this->assertSame('valid', $wp_options_mock['sbr_settings']['license_status'], 'never clear on a soft signal'); |
| 192 | $this->assertNotEmpty($wp_options_mock['sbr_license_reconcile_1585_done'], 'done flag set so we do not retry forever'); |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * Real-world migration case: the forked token was later deleted/revoked on |
| 197 | * the relay, so the ping is a 401 error envelope (success false, no pong). |
| 198 | * The routine must NOT clear a still-"valid" local license on a relay auth |
| 199 | * error — that would log the customer out on a transient/relay-side problem. |
| 200 | * A dead token is the register/reverify path's job, not this one. |
| 201 | */ |
| 202 | public function test_run_does_not_clear_on_dead_or_revoked_token_error(): void |
| 203 | { |
| 204 | global $wp_options_mock; |
| 205 | $wp_options_mock['sbr_settings'] = $this->licensedSettings(); |
| 206 | $wp_options_mock['sbr_statuses'] = ['license_tier' => 3]; |
| 207 | |
| 208 | $routine = $this->routineWithPing(['success' => false, 'errorId' => 'invalidToken', 'apiMessage' => 'Token is invalid']); |
| 209 | $routine->run(); |
| 210 | |
| 211 | $this->assertSame('valid', $wp_options_mock['sbr_settings']['license_status'], 'dead-token error must not clear a valid local license'); |
| 212 | $this->assertSame(3, $wp_options_mock['sbr_statuses']['license_tier'], 'tier untouched on a relay auth error'); |
| 213 | $this->assertNotEmpty($wp_options_mock['sbr_license_reconcile_1585_done'], 'still one-shot'); |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * Real-world case: a backup/migration tool corrupted sbr_settings so it is |
| 218 | * no longer an array. Even on a confirmed wedge ping, run() must guard and |
| 219 | * no-op rather than fatal on string offset access. |
| 220 | */ |
| 221 | public function test_run_survives_corrupted_non_array_settings_on_wedge(): void |
| 222 | { |
| 223 | global $wp_options_mock; |
| 224 | $wp_options_mock['sbr_settings'] = 'corrupted-not-an-array'; |
| 225 | |
| 226 | $routine = $this->routineWithPing(['success' => true, 'pong' => true, 'license_active' => false]); |
| 227 | $routine->run(); // must not throw |
| 228 | |
| 229 | $this->assertSame('corrupted-not-an-array', $wp_options_mock['sbr_settings'], 'left untouched, no fatal'); |
| 230 | $this->assertNotEmpty($wp_options_mock['sbr_license_reconcile_1585_done'], 'still marked done'); |
| 231 | } |
| 232 | } |
| 233 |