feat: Divi tab with REST check, release and withdrawal
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Admin\AdminPostAction;
|
||||
use LIT\MCP\Admin\DiviTab;
|
||||
use LIT\MCP\Integrations\Divi\Release\CheckReport;
|
||||
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
|
||||
use LIT\MCP\Integrations\Divi\Release\CheckStep;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
|
||||
use LIT\MCP\Plugin;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviTabTest extends TestCase
|
||||
{
|
||||
private ?string $redirect = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->redirect = null;
|
||||
Functions\stubEscapeFunctions();
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('check_admin_referer')->justReturn(1);
|
||||
Functions\when('get_current_user_id')->justReturn(1);
|
||||
Functions\when('wp_safe_redirect')->alias(function (string $url): bool {
|
||||
$this->redirect = $url;
|
||||
|
||||
return true;
|
||||
});
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
|
||||
Functions\when('add_query_arg')->alias(
|
||||
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
|
||||
);
|
||||
}
|
||||
|
||||
private static function report(bool $green = true, string $version = '5.3.0', string $key = 'key'): CheckReport
|
||||
{
|
||||
$steps = array_map(
|
||||
static fn (string $step): CheckStep => new CheckStep($step, $green ? [] : ['rot']),
|
||||
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
|
||||
);
|
||||
|
||||
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps);
|
||||
}
|
||||
|
||||
private function tab(?CheckReport $report, ReleaseStore $releases, ?FakeChangeLog $log = null): DiviTab
|
||||
{
|
||||
$reports = Mockery::mock(CheckReportStore::class);
|
||||
$reports->allows('latest')->andReturn($report);
|
||||
|
||||
return new DiviTab(
|
||||
$reports,
|
||||
$releases,
|
||||
$log ?? FakeChangeLog::forbidden(),
|
||||
new AdminPostAction(static function (): void {
|
||||
}),
|
||||
static fn (): string => '5.3.0',
|
||||
static fn (string $version): string => 'key'
|
||||
);
|
||||
}
|
||||
|
||||
public function testReleaseNeedsAFreshGreenReport(): void
|
||||
{
|
||||
$reports = [null, self::report(false), self::report(true, '5.2.9'), self::report(true, '5.3.0', 'old')];
|
||||
foreach ($reports as $report) {
|
||||
$releases = Mockery::mock(ReleaseStore::class);
|
||||
$releases->expects('add')->never();
|
||||
|
||||
$this->tab($report, $releases)->release();
|
||||
|
||||
self::assertStringContainsString('lit_mcp_notice=divi_release_refused', (string) $this->redirect);
|
||||
}
|
||||
}
|
||||
|
||||
public function testReleaseStoresTheEntryAndLogsIt(): void
|
||||
{
|
||||
$releases = Mockery::mock(ReleaseStore::class);
|
||||
$releases->expects('add')->once()->with('5.3.0', Plugin::VERSION, 1, 'r1');
|
||||
$log = new FakeChangeLog();
|
||||
|
||||
$this->tab(self::report(), $releases, $log)->release();
|
||||
|
||||
self::assertStringContainsString('lit_mcp_notice=divi_released', (string) $this->redirect);
|
||||
self::assertSame('lit/divi-release', $log->records[0]['ability']);
|
||||
self::assertStringContainsString('Divi 5.3.0', $log->records[0]['summary']);
|
||||
}
|
||||
|
||||
public function testWithdrawRemovesTheEntryOfTheInstalledVersion(): void
|
||||
{
|
||||
$releases = Mockery::mock(ReleaseStore::class);
|
||||
$releases->expects('withdraw')->once()->with('5.3.0', Plugin::VERSION)->andReturn(true);
|
||||
$log = new FakeChangeLog();
|
||||
|
||||
$this->tab(null, $releases, $log)->withdraw();
|
||||
|
||||
self::assertStringContainsString('lit_mcp_notice=divi_withdrawn', (string) $this->redirect);
|
||||
self::assertSame('lit/divi-release', $log->records[0]['ability']);
|
||||
}
|
||||
|
||||
public function testActionsNeedNonceAndManageOptions(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
Functions\expect('wp_die')->twice()->andReturnUsing(static function (): never {
|
||||
throw new \RuntimeException('died');
|
||||
});
|
||||
$releases = Mockery::mock(ReleaseStore::class);
|
||||
$releases->expects('add')->never();
|
||||
$releases->expects('withdraw')->never();
|
||||
$tab = $this->tab(self::report(), $releases);
|
||||
|
||||
foreach (['release', 'withdraw'] as $method) {
|
||||
try {
|
||||
$tab->{$method}();
|
||||
self::fail($method . ' without manage_options');
|
||||
} catch (\RuntimeException $e) {
|
||||
self::assertSame('died', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function testReleaseChecksTheNonce(): void
|
||||
{
|
||||
Functions\when('check_admin_referer')->alias(static function (string $action): never {
|
||||
throw new \RuntimeException('bad nonce for ' . $action);
|
||||
});
|
||||
$releases = Mockery::mock(ReleaseStore::class);
|
||||
$releases->expects('add')->never();
|
||||
|
||||
$this->expectExceptionMessage('bad nonce for lit_mcp_divi_release');
|
||||
$this->tab(self::report(), $releases)->release();
|
||||
}
|
||||
|
||||
/** @return string The rendered tab. */
|
||||
private function rendered(ReleaseStatus $status, ?CheckReport $report): string
|
||||
{
|
||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
|
||||
Functions\when('get_posts')->justReturn([]);
|
||||
Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');
|
||||
Functions\when('wp_create_nonce')->justReturn('n');
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('submit_button')->alias(static function (string $text): void {
|
||||
echo '<button>' . $text . '</button>';
|
||||
});
|
||||
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan']);
|
||||
Functions\when('date_i18n')->justReturn('01.10.2026 10:00');
|
||||
Functions\when('get_option')->justReturn('d.m.Y');
|
||||
|
||||
ob_start();
|
||||
$this->tab($report, Mockery::mock(ReleaseStore::class))->render();
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
public function testUntestedWithAFreshGreenReportOffersTheRelease(): void
|
||||
{
|
||||
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report());
|
||||
|
||||
self::assertStringContainsString('ungetestet', $html);
|
||||
self::assertStringContainsString('Prüfung starten', $html);
|
||||
self::assertStringContainsString('Divi 5.3.0 freigeben', $html);
|
||||
self::assertStringContainsString('name="_wpnonce" value="n"', $html);
|
||||
}
|
||||
|
||||
public function testUntestedWithAStaleReportAsksForANewCheck(): void
|
||||
{
|
||||
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9'));
|
||||
|
||||
self::assertStringNotContainsString('Divi 5.3.0 freigeben', $html);
|
||||
self::assertStringContainsString('Prüfung erneut ausführen', $html);
|
||||
}
|
||||
|
||||
public function testReleasedFromCodeOffersNeitherReleaseNorWithdrawal(): void
|
||||
{
|
||||
$html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report());
|
||||
|
||||
self::assertStringContainsString('freigegeben', $html);
|
||||
self::assertStringContainsString('im Plugin als getestet hinterlegt', $html);
|
||||
self::assertStringNotContainsString('freigeben</button>', $html);
|
||||
self::assertStringNotContainsString('Freigabe zurückziehen', $html);
|
||||
}
|
||||
|
||||
public function testADatabaseReleaseCanBeWithdrawn(): void
|
||||
{
|
||||
$status = new ReleaseStatus(ReleaseState::Released, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [
|
||||
'released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1,
|
||||
]);
|
||||
|
||||
self::assertStringContainsString('Freigabe zurückziehen', $this->rendered($status, self::report()));
|
||||
}
|
||||
|
||||
public function testIncompatibleListsTheProblems(): void
|
||||
{
|
||||
$html = $this->rendered(
|
||||
new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['Theme Builder: Nachbau von x() – y']),
|
||||
null
|
||||
);
|
||||
|
||||
self::assertStringContainsString('inkompatibel', $html);
|
||||
self::assertStringContainsString('Theme Builder: Nachbau von x() – y', $html);
|
||||
self::assertStringContainsString('Plugin-Update', $html);
|
||||
}
|
||||
}
|
||||
@@ -158,4 +158,31 @@ final class SettingsPageTest extends TestCase
|
||||
|
||||
$page->register();
|
||||
}
|
||||
|
||||
/** @dataProvider diviNotices */
|
||||
public function testDiviNoticesAreShown(string $key, string $class, string $text): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$_GET['lit_mcp_notice'] = $key;
|
||||
|
||||
ob_start();
|
||||
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('notice-' . $class, $html);
|
||||
self::assertStringContainsString($text, $html);
|
||||
}
|
||||
|
||||
/** @return list<list<string>> */
|
||||
public static function diviNotices(): array
|
||||
{
|
||||
return [
|
||||
['divi_checked', 'success', 'Divi-Prüfung abgeschlossen'],
|
||||
['divi_check_running', 'warning', 'bereits eine Divi-Prüfung'],
|
||||
['divi_released', 'success', 'für LIT MCP freigegeben'],
|
||||
['divi_release_refused', 'error', 'Prüfung erneut ausführen'],
|
||||
['divi_withdrawn', 'success', 'Freigabe zurückgezogen'],
|
||||
['divi_withdraw_failed', 'error', 'keine Freigabe'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Release\CheckReport;
|
||||
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
|
||||
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviCheckRouteTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
|
||||
Functions\when('add_query_arg')->alias(
|
||||
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
|
||||
);
|
||||
Functions\when('get_current_user_id')->justReturn(1);
|
||||
}
|
||||
|
||||
public function testRegistersAPostRouteForAdministratorsOnly(): void
|
||||
{
|
||||
Functions\expect('register_rest_route')->once()->with(
|
||||
'lit-mcp/v1',
|
||||
'/divi-check',
|
||||
Mockery::on(static function (array $args): bool {
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'manage_options');
|
||||
$allowed = ($args['permission_callback'])();
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
|
||||
return $args['methods'] === 'POST' && $allowed === true && ($args['permission_callback'])() === false;
|
||||
})
|
||||
);
|
||||
|
||||
(new DiviCheckRoute(static fn () => null))->registerRoute();
|
||||
}
|
||||
|
||||
public function testRunsTheCheckStoresTheReportAndRedirectsToTheTab(): void
|
||||
{
|
||||
$report = new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', []);
|
||||
$check = Mockery::mock(CompatibilityCheck::class);
|
||||
$check->expects('run')->with(1)->andReturn($report);
|
||||
$reports = Mockery::mock(CheckReportStore::class);
|
||||
$reports->expects('save')->with($report);
|
||||
Functions\expect('add_option')
|
||||
->once()
|
||||
->with(DiviCheckRoute::LOCK, Mockery::type('int'), '', false)
|
||||
->andReturn(true);
|
||||
Functions\expect('delete_option')->once()->with(DiviCheckRoute::LOCK);
|
||||
|
||||
$response = (new DiviCheckRoute(static fn () => $check, $reports))->handle();
|
||||
|
||||
self::assertSame(303, $response->get_status());
|
||||
self::assertStringContainsString('tab=divi', $response->get_headers()['Location']);
|
||||
self::assertStringContainsString('lit_mcp_notice=divi_checked', $response->get_headers()['Location']);
|
||||
}
|
||||
|
||||
public function testASecondCheckWaitsForTheFirst(): void
|
||||
{
|
||||
Functions\when('add_option')->justReturn(false);
|
||||
Functions\when('get_option')->alias(static fn (): int => time());
|
||||
Functions\expect('delete_option')->never();
|
||||
$reports = Mockery::mock(CheckReportStore::class);
|
||||
$reports->expects('save')->never();
|
||||
|
||||
$response = (new DiviCheckRoute(static fn () => self::fail('no second check'), $reports))->handle();
|
||||
|
||||
self::assertStringContainsString('lit_mcp_notice=divi_check_running', $response->get_headers()['Location']);
|
||||
}
|
||||
|
||||
public function testAStaleLockIsTakenOver(): void
|
||||
{
|
||||
Functions\when('add_option')->alias(static function (): bool {
|
||||
static $calls = 0;
|
||||
|
||||
return ++$calls > 1;
|
||||
});
|
||||
Functions\when('get_option')->justReturn(1);
|
||||
Functions\expect('delete_option')->twice()->with(DiviCheckRoute::LOCK);
|
||||
$check = Mockery::mock(CompatibilityCheck::class);
|
||||
$check->expects('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', []));
|
||||
$reports = Mockery::mock(CheckReportStore::class);
|
||||
$reports->expects('save');
|
||||
|
||||
(new DiviCheckRoute(static fn () => $check, $reports))->handle();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ use LIT\MCP\Access;
|
||||
use LIT\MCP\Admin\ConnectionStatus;
|
||||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||||
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
|
||||
use LIT\MCP\Limits;
|
||||
use Mockery;
|
||||
|
||||
@@ -33,7 +37,11 @@ final class UninstallTest extends TestCase
|
||||
Functions\when('get_sites')->justReturn([1, 2]);
|
||||
Functions\expect('switch_to_blog')->twice();
|
||||
Functions\expect('restore_current_blog')->twice();
|
||||
Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT);
|
||||
$transients = [];
|
||||
Functions\when('delete_transient')->alias(static function (string $transient) use (&$transients): bool {
|
||||
$transients[] = $transient;
|
||||
return true;
|
||||
});
|
||||
Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK);
|
||||
Functions\expect('delete_metadata')->once()->with('user', 0, 'lit_mcp_log_per_page', '', true);
|
||||
$deleted = [];
|
||||
@@ -44,9 +52,19 @@ final class UninstallTest extends TestCase
|
||||
|
||||
require dirname(__DIR__, 2) . '/uninstall.php';
|
||||
|
||||
$own = [Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION];
|
||||
$own = [
|
||||
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
||||
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK,
|
||||
];
|
||||
self::assertSame(array_merge($own, $own), $deleted);
|
||||
self::assertNotContains('mcp_jwt_secret', $deleted);
|
||||
self::assertNotContains('wpmedia_mcp_oauth_rewrite_version', $deleted);
|
||||
self::assertSame(
|
||||
[
|
||||
ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE,
|
||||
ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE,
|
||||
],
|
||||
$transients
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ use LIT\MCP\Admin\ConnectionStatus;
|
||||
use LIT\MCP\Admin\LogTab;
|
||||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||||
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
|
||||
use LIT\MCP\Limits;
|
||||
use LIT\MCP\Uninstaller;
|
||||
use Mockery;
|
||||
@@ -36,7 +40,11 @@ final class UninstallerTest extends TestCase
|
||||
Functions\when('is_multisite')->justReturn(false);
|
||||
Functions\expect('switch_to_blog')->never();
|
||||
Functions\expect('restore_current_blog')->never();
|
||||
Functions\expect('delete_transient')->once()->with(ConnectionStatus::ENDPOINT_TRANSIENT);
|
||||
$transients = [];
|
||||
Functions\when('delete_transient')->alias(static function (string $transient) use (&$transients): bool {
|
||||
$transients[] = $transient;
|
||||
return true;
|
||||
});
|
||||
Functions\expect('wp_clear_scheduled_hook')->once()->with(ChangeLogCleanup::HOOK);
|
||||
// Review m-8: the Protokoll screen option is user meta (network-wide): removed once.
|
||||
Functions\expect('delete_metadata')->once()->with('user', 0, LogTab::PER_PAGE_OPTION, '', true);
|
||||
@@ -48,8 +56,12 @@ final class UninstallerTest extends TestCase
|
||||
|
||||
Uninstaller::run();
|
||||
|
||||
self::assertSame([ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE], $transients);
|
||||
self::assertSame(
|
||||
[Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION],
|
||||
[
|
||||
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
||||
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK,
|
||||
],
|
||||
$deleted
|
||||
);
|
||||
}
|
||||
@@ -63,11 +75,23 @@ final class UninstallerTest extends TestCase
|
||||
Functions\when('get_sites')->justReturn([1, 2]);
|
||||
Functions\expect('switch_to_blog')->twice();
|
||||
Functions\expect('restore_current_blog')->twice();
|
||||
Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT);
|
||||
$transients = [];
|
||||
Functions\when('delete_transient')->alias(static function (string $transient) use (&$transients): bool {
|
||||
$transients[] = $transient;
|
||||
return true;
|
||||
});
|
||||
Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK);
|
||||
Functions\expect('delete_metadata')->once()->with('user', 0, LogTab::PER_PAGE_OPTION, '', true);
|
||||
Functions\when('delete_option')->justReturn(true);
|
||||
|
||||
Uninstaller::run();
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE,
|
||||
ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE,
|
||||
],
|
||||
$transients
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user