CI / Coding Standards (pull_request) Successful in 19s
CI / No Debug Code (pull_request) Successful in 5s
CI / Tests (PHP 8.2) (pull_request) Successful in 33s
CI / Static Analysis (pull_request) Successful in 42s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 45s
CI / Tests (PHP 8.5) (pull_request) Successful in 44s
CI / Build Plugin Zip (pull_request) Skipped
Both were mine, and both were in code the earlier commit could not run. The four test failures shared one cause: UninstallerTest stubbed get_option with an arrow function, which captures by value, so every read answered from a snapshot of the options taken at setUp — before the test set any and before the run wrote any. Every assertion that depended on reading back what had just been written therefore saw an empty store. The file's other stubs already use by-reference closures; this one now does too. The phpcs error is WordPress.DB.PreparedSQL.NotPrepared on the table drop. The sniff cannot follow $sql across the null guard that PHPStan requires (prepare() is nullable), and unlike the repositories — which call through a typed $this->db property the sniff does not track at all — the uninstaller calls the global $wpdb, so the sniff sees it. Silenced explicitly, with the reason. composer test (996 tests, 2871 assertions), composer lint and composer cs all pass locally on PHP 8.4. Co-Authored-By: Claude Opus 5 <[email protected]>
244 lines
8.9 KiB
PHP
244 lines
8.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
|
use Unsupervised\Schedular\Schema;
|
|
use Unsupervised\Schedular\Uninstaller;
|
|
|
|
class UninstallerTest extends TestCase
|
|
{
|
|
/** @var array<string, mixed> Option store the stubs read and write. */
|
|
private array $options = [];
|
|
|
|
/** @var list<string> Options deleted during the run. */
|
|
private array $deleted = [];
|
|
|
|
/** @var list<string> SQL statements sent to the database. */
|
|
private array $queries = [];
|
|
|
|
/** @var list<string> User meta keys deleted for every user. */
|
|
private array $metaDeleted = [];
|
|
|
|
/** @var list<string> Roles removed. */
|
|
private array $rolesRemoved = [];
|
|
|
|
/** @var list<string> 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;
|
|
|
|
// A regular closure, not an arrow fn: arrow functions capture by value,
|
|
// so every read would answer from a snapshot of the options taken at
|
|
// setUp — before the test set any, and before the run wrote any.
|
|
Functions\when('get_option')->alias(
|
|
static function (string $key, mixed $default = false) use (&$options): mixed {
|
|
return $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());
|
|
}
|
|
}
|