Option store the stubs read and write. */ private array $options = []; /** @var list Options deleted during the run. */ private array $deleted = []; /** @var list SQL statements sent to the database. */ private array $queries = []; /** @var list User meta keys deleted for every user. */ private array $metaDeleted = []; /** @var list Roles removed. */ private array $rolesRemoved = []; /** @var list Cron hooks cleared. */ private array $hooksCleared = []; protected function setUp(): void { parent::setUp(); $options = &$this->options; $deleted = &$this->deleted; $metaDeleted = &$this->metaDeleted; $rolesRemoved = &$this->rolesRemoved; $hooksCleared = &$this->hooksCleared; Functions\when('get_option')->alias( static fn(string $key, mixed $default = false): mixed => $options[$key] ?? $default ); Functions\when('update_option')->alias( static function (string $key, mixed $value) use (&$options): bool { $options[$key] = $value; return true; } ); Functions\when('delete_option')->alias( static function (string $key) use (&$options, &$deleted): bool { unset($options[$key]); $deleted[] = $key; return true; } ); Functions\when('delete_transient')->justReturn(true); Functions\when('wp_clear_scheduled_hook')->alias( static function (string $hook) use (&$hooksCleared): int { $hooksCleared[] = $hook; return 0; } ); Functions\when('delete_metadata')->alias( static function (string $type, int $id, string $key) use (&$metaDeleted): bool { $metaDeleted[] = $key; return true; } ); Functions\when('remove_role')->alias( static function (string $role) use (&$rolesRemoved): void { $rolesRemoved[] = $role; } ); $db = Mockery::mock(\wpdb::class); $db->prefix = 'wp_'; $queries = &$this->queries; $db->shouldReceive('prepare')->andReturnUsing( static fn(string $sql, mixed ...$args): string => str_replace('%i', (string) $args[0], $sql) ); $db->shouldReceive('query')->andReturnUsing( static function (string $sql) use (&$queries): int { $queries[] = $sql; return 1; } ); $GLOBALS['wpdb'] = $db; } protected function tearDown(): void { unset($GLOBALS['wpdb']); parent::tearDown(); } /** * A Stripe secret is a credential, not a record. It can be pasted back in * from the Stripe dashboard in a minute, and a site that no longer has the * code to use it has no business still holding it — so it goes whether or * not the studio asked to keep its data. */ public function testStripeCredentialsAreForgottenEvenWhenTheDataIsKept(): void { $this->options = [ StudioSettings::OPT_SECRET => 'sk_live_secret', StudioSettings::OPT_WEBHOOK_SECRET => 'whsec_secret', StudioSettings::OPT_PUBLISHABLE => 'pk_live_key', StudioSettings::OPT_MODE => 'live', StudioSettings::OPT_HST_RATE => '13', ]; (new Uninstaller())->run(); self::assertArrayNotHasKey(StudioSettings::OPT_SECRET, $this->options); self::assertArrayNotHasKey(StudioSettings::OPT_WEBHOOK_SECRET, $this->options); self::assertArrayNotHasKey(StudioSettings::OPT_PUBLISHABLE, $this->options); self::assertArrayNotHasKey(StudioSettings::OPT_MODE, $this->options); // The studio's own settings are records, and stay. self::assertSame('13', $this->options[StudioSettings::OPT_HST_RATE]); } public function testKeepingDataDropsNoTablesAndRemovesNoRoles(): void { $this->options = [Uninstaller::OPT_DELETE_DATA => '0']; (new Uninstaller())->run(); self::assertSame([], $this->queries); self::assertSame([], $this->metaDeleted); // A site keeping its data keeps the roles its students hold, or every one // of them is left with no capabilities until the plugin is reinstalled. self::assertSame([], $this->rolesRemoved); } public function testFullPurgeDropsEveryTableTheSchemaDeclares(): void { $this->options = [Uninstaller::OPT_DELETE_DATA => '1']; (new Uninstaller())->run(); self::assertCount(count(Schema::TABLES), $this->queries); foreach (Schema::TABLES as $table) { self::assertContains('DROP TABLE IF EXISTS wp_' . $table, $this->queries); } } public function testFullPurgeClearsSettingsUserMetaAndRoles(): void { $this->options = [ Uninstaller::OPT_DELETE_DATA => '1', 'us_schedular_version' => '1.5.6', StudioSettings::OPT_HST_RATE => '13', StudioSettings::OPT_CURRENCY => 'CAD', ]; (new Uninstaller())->run(); self::assertContains('us_schedular_version', $this->deleted); self::assertContains(StudioSettings::OPT_HST_RATE, $this->deleted); self::assertContains(Uninstaller::OPT_DELETE_DATA, $this->deleted); // Nothing student-shaped is left hanging off a user account. self::assertContains('us_payment_method', $this->metaDeleted); self::assertContains('us_child', $this->metaDeleted); self::assertContains('us_awaiting_approval', $this->metaDeleted); self::assertSame( [RoleManager::STUDIO_ADMIN, RoleManager::INSTRUCTOR, RoleManager::STUDENT], $this->rolesRemoved ); } /** * Open registration switches the site's own `users_can_register` on and makes * Student the default role. Leaving those behind would leave the site taking * public signups into a role that is, one line later, about to stop existing. */ public function testCoreRegistrationSettingsAreRestoredFromTheSnapshot(): void { $this->options = [ 'users_can_register' => '1', 'default_role' => RoleManager::STUDENT, StudioSettings::OPT_PREV_USERS_CAN_REGISTER => '0', StudioSettings::OPT_PREV_DEFAULT_ROLE => 'subscriber', ]; (new Uninstaller())->run(); self::assertSame('0', $this->options['users_can_register']); self::assertSame('subscriber', $this->options['default_role']); // The snapshot is spent. self::assertArrayNotHasKey(StudioSettings::OPT_PREV_USERS_CAN_REGISTER, $this->options); self::assertArrayNotHasKey(StudioSettings::OPT_PREV_DEFAULT_ROLE, $this->options); } public function testSiteThatNeverOpenedRegistrationKeepsItsOwnSettings(): void { $this->options = ['users_can_register' => '1', 'default_role' => 'contributor']; (new Uninstaller())->run(); // No snapshot means the plugin never touched these, so nor does this. self::assertSame('1', $this->options['users_can_register']); self::assertSame('contributor', $this->options['default_role']); } public function testTheScheduledBillingEventIsAlwaysCleared(): void { // Deactivation clears it too, and always precedes a delete — but a site // whose plugin files simply vanished never ran that hook, and a schedule // pointing at code that is gone is left firing into nothing. (new Uninstaller())->run(); self::assertSame([ScheduledBillingRunner::HOOK], $this->hooksCleared); } public function testSettingIsOffUntilItIsExplicitlyTurnedOn(): void { self::assertFalse(Uninstaller::deletesDataOnUninstall()); Uninstaller::setDeletesDataOnUninstall(true); self::assertTrue(Uninstaller::deletesDataOnUninstall()); Uninstaller::setDeletesDataOnUninstall(false); self::assertFalse(Uninstaller::deletesDataOnUninstall()); } }