From 6113e5ad5aa23a65d6f14212f9cd5c5c647c1815 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 02:39:21 +0200 Subject: [PATCH] feat: Divi tab with REST check, release and withdrawal Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Admin/DiviTab.php | 296 ++++++++++++++++++ src/Admin/SettingsPage.php | 22 ++ .../Divi/Release/DiviCheckRoute.php | 88 ++++++ src/Plugin.php | 8 + src/Uninstaller.php | 29 +- tests/Stubs/WpRestResponse.php | 32 ++ tests/Unit/Admin/DiviTabTest.php | 213 +++++++++++++ tests/Unit/Admin/SettingsPageTest.php | 27 ++ .../Divi/Release/DiviCheckRouteTest.php | 93 ++++++ tests/Unit/UninstallTest.php | 22 +- tests/Unit/UninstallerTest.php | 30 +- tests/bootstrap.php | 1 + uninstall.php | 6 +- 13 files changed, 849 insertions(+), 18 deletions(-) create mode 100644 src/Admin/DiviTab.php create mode 100644 src/Integrations/Divi/Release/DiviCheckRoute.php create mode 100644 tests/Stubs/WpRestResponse.php create mode 100644 tests/Unit/Admin/DiviTabTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php new file mode 100644 index 0000000..263707b --- /dev/null +++ b/src/Admin/DiviTab.php @@ -0,0 +1,296 @@ + freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only + * for a database release). Opening the tab removes check pages a dead check left behind. + */ +final class DiviTab implements AdminTab +{ + public const SLUG = 'divi'; + public const RELEASE_ACTION = 'lit_mcp_divi_release'; + public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw'; + + private const STATES = [ + 'released' => 'freigegeben', + 'untested' => 'ungetestet', + 'incompatible' => 'inkompatibel', + ]; + + /** + * @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment. + * @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey(). + */ + public function __construct( + private ?CheckReportStore $reports = null, + private ?ReleaseStore $releases = null, + private ?ChangeLog $log = null, + private ?AdminPostAction $action = null, + private ?Closure $version = null, + private ?Closure $stateKey = null + ) { + } + + public function slug(): string + { + return self::SLUG; + } + + public function label(): string + { + return __('Divi', 'wp-lit-mcp'); + } + + public function registerSettings(): void + { + } + + public function registerActions(): void + { + add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']); + add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']); + } + + public function release(): void + { + $action = $this->action ?? new AdminPostAction(); + $action->guard(); + check_admin_referer(self::RELEASE_ACTION); + + $version = $this->version(); + $report = $this->reports()->latest(); + $fresh = $report !== null + && $report->freshFor($version, Plugin::VERSION, $this->stateKey($version)); + if ($version === '' || ! $fresh) { + $action->redirect(self::SLUG, 'divi_release_refused'); + + return; + } + + ($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id); + ReleaseGate::forget(); + $this->log?->record( + 'lit/divi-release', + 'divi', + $version, + sprintf('Divi %s für LIT MCP %s freigegeben (Prüfbericht %s).', $version, Plugin::VERSION, $report->id) + ); + $action->redirect(self::SLUG, 'divi_released'); + } + + public function withdraw(): void + { + $action = $this->action ?? new AdminPostAction(); + $action->guard(); + check_admin_referer(self::WITHDRAW_ACTION); + + $version = $this->version(); + if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) { + $action->redirect(self::SLUG, 'divi_withdraw_failed'); + + return; + } + + ReleaseGate::forget(); + $this->log?->record( + 'lit/divi-release', + 'divi', + $version, + sprintf('Freigabe von Divi %s zurückgezogen.', $version) + ); + $action->redirect(self::SLUG, 'divi_withdrawn'); + } + + public function render(): void + { + $removed = WriteProbe::removeOrphans(); + $status = ReleaseGate::status(); + $report = $this->reports()->latest(); + + echo '

' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '

'; + echo '

' . esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'LIT MCP schreibt nur mit Divi-Versionen, die geprüft sind. Nach einem Divi-Update hier die Prüfung starten und die Version freigeben; bis dahin lesen die Divi-Werkzeuge nur.', + 'wp-lit-mcp' + ) . '

'; + if ($removed !== []) { + printf( + '

%s

', + esc_html(sprintf( + /* translators: %s: comma separated page IDs */ + __('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'), + implode(', ', $removed) + )) + ); + } + + $this->renderStatus($status); + $this->renderForms($status, $report); + if ($report !== null) { + $this->renderReport($report); + } + } + + private function renderStatus(ReleaseStatus $status): void + { + echo ''; + printf( + '', + esc_html__('Divi-Version', 'wp-lit-mcp'), + esc_html($status->version === '' ? __('nicht erkannt', 'wp-lit-mcp') : $status->version) + ); + printf( + '', + esc_html__('Zustand', 'wp-lit-mcp'), + esc_html(self::STATES[$status->state->value]), + esc_html($this->explanation($status)) + ); + if ($status->problems !== []) { + echo ''; + } + echo ''; + } + + private function explanation(ReleaseStatus $status): string + { + if ($status->state === ReleaseState::Incompatible) { + return __( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Divi hat Stellen geändert, die LIT MCP nutzt. Schreiben bleibt gesperrt, bis ein Plugin-Update von LIT MCP eingespielt ist.', + 'wp-lit-mcp' + ); + } + if ($status->state === ReleaseState::Untested) { + return __( + 'Diese Version ist nicht geprüft. Die Divi-Werkzeuge lesen nur, bis sie freigegeben ist.', + 'wp-lit-mcp' + ); + } + if ($status->source === ReleaseStatus::SOURCE_CODE) { + return __('Diese Version ist im Plugin als getestet hinterlegt.', 'wp-lit-mcp'); + } + + $user = get_userdata((int) ($status->release['user_id'] ?? 0)); + $releasedAt = (int) strtotime((string) ($status->release['released_at'] ?? '')); + + return sprintf( + /* translators: 1: date, 2: user name */ + __('Auf dieser Website freigegeben am %1$s von %2$s.', 'wp-lit-mcp'), + date_i18n((string) get_option('date_format'), $releasedAt), + is_object($user) && isset($user->display_name) ? (string) $user->display_name : '?' + ); + } + + private function renderForms(ReleaseStatus $status, ?CheckReport $report): void + { + printf('
', esc_url(DiviCheckRoute::url())); + printf('', esc_attr(wp_create_nonce('wp_rest'))); + submit_button(__('Prüfung starten', 'wp-lit-mcp'), 'secondary', 'lit-mcp-divi-check', false); + echo '
'; + + if ($status->state === ReleaseState::Untested && $status->version !== '') { + $fresh = $report !== null + && $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version)); + if ($fresh) { + $this->actionForm( + self::RELEASE_ACTION, + /* translators: %s: Divi version */ + sprintf(__('Divi %s freigeben', 'wp-lit-mcp'), $status->version), + 'primary' + ); + } else { + echo '

' . esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Divi-Version – Prüfung erneut ausführen.', + 'wp-lit-mcp' + ) . '

'; + } + } + + if ($status->state === ReleaseState::Released && $status->source === ReleaseStatus::SOURCE_DATABASE) { + $this->actionForm(self::WITHDRAW_ACTION, __('Freigabe zurückziehen', 'wp-lit-mcp'), 'secondary'); + } + } + + private function actionForm(string $action, string $label, string $type): void + { + printf('
', esc_url(admin_url('admin-post.php'))); + printf('', esc_attr($action)); + wp_nonce_field($action); + submit_button($label, $type, 'submit', false); + echo '
'; + } + + private function renderReport(CheckReport $report): void + { + printf( + '

%s

%s

' . esc_html__( + 'Optional: eine Divi-Seite im Visual Builder öffnen und speichern.', + 'wp-lit-mcp' + ) . '

'; + } + + private function version(): string + { + return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')(); + } + + private function stateKey(string $version): string + { + return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version); + } + + private function reports(): CheckReportStore + { + return $this->reports ?? new CheckReportStore(); + } +} diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php index b35d112..0d00b67 100644 --- a/src/Admin/SettingsPage.php +++ b/src/Admin/SettingsPage.php @@ -160,6 +160,28 @@ final class SettingsPage 'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')], 'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')], 'cleared' => ['success', __('Protokoll geleert.', 'wp-lit-mcp')], + 'divi_checked' => ['success', __('Divi-Prüfung abgeschlossen; das Ergebnis steht unten.', 'wp-lit-mcp')], + 'divi_check_running' => [ + 'warning', + __('Es läuft bereits eine Divi-Prüfung; bitte in ein paar Minuten erneut versuchen.', 'wp-lit-mcp'), + ], + 'divi_released' => ['success', __('Die Divi-Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')], + 'divi_release_refused' => [ + 'error', + __( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Freigabe nicht möglich: Der letzte Prüfbericht ist nicht grün oder passt nicht mehr zur installierten Divi- oder Plugin-Version. Bitte die Prüfung erneut ausführen.', + 'wp-lit-mcp' + ), + ], + 'divi_withdrawn' => [ + 'success', + __('Freigabe zurückgezogen; schreibende Divi-Werkzeuge sind wieder gesperrt.', 'wp-lit-mcp'), + ], + 'divi_withdraw_failed' => [ + 'error', + __('Für die installierte Divi-Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'), + ], default => null, }; diff --git a/src/Integrations/Divi/Release/DiviCheckRoute.php b/src/Integrations/Divi/Release/DiviCheckRoute.php new file mode 100644 index 0000000..c1ae3df --- /dev/null +++ b/src/Integrations/Divi/Release/DiviCheckRoute.php @@ -0,0 +1,88 @@ + 'POST', + 'callback' => [$this, 'handle'], + 'permission_callback' => static fn (): bool => current_user_can('manage_options'), + ]); + } + + public static function url(): string + { + return rest_url(self::NAMESPACE . self::ROUTE); + } + + public function handle(): WP_REST_Response + { + $notice = 'divi_check_running'; + if (self::lock()) { + try { + $report = ($this->check)()->run(get_current_user_id()); + ($this->reports ?? new CheckReportStore())->save($report); + ReleaseGate::forget(); + $notice = 'divi_checked'; + } finally { + delete_option(self::LOCK); + } + } + + return new WP_REST_Response( + null, + 303, + ['Location' => SettingsPage::url(DiviTab::SLUG, ['lit_mcp_notice' => $notice])] + ); + } + + private static function lock(): bool + { + if (add_option(self::LOCK, time(), '', false)) { + return true; + } + + $since = (int) get_option(self::LOCK, 0); + if ($since > 0 && time() - $since < self::LOCK_TTL) { + return false; + } + delete_option(self::LOCK); + + return (bool) add_option(self::LOCK, time(), '', false); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index fdb5e34..16468cb 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,6 +12,7 @@ use LIT\MCP\Admin\ConnectionRepository; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\ConnectionTab; use LIT\MCP\Admin\ConnectionUsage; +use LIT\MCP\Admin\DiviTab; use LIT\MCP\Admin\LimitsTab; use LIT\MCP\Admin\LogTab; use LIT\MCP\Admin\SettingsPage; @@ -24,6 +25,8 @@ use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; +use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviToolGate; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; @@ -134,6 +137,10 @@ final class Plugin $divi->themeBuilder() ); + // Spec §6: the admin check runs as a REST request (same context as MCP calls). + $diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer()); + (new DiviCheckRoute($diviCheck))->register(); + $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); (new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register(); (new ServerRegistrar( @@ -153,6 +160,7 @@ final class Plugin new ToolsTab($registry, $policy), new LogTab($changeLog, $limits, $registry), new LimitsTab(), + new DiviTab(log: $changeLog), ]; self::$settingsPage = new SettingsPage( $tabs, diff --git a/src/Uninstaller.php b/src/Uninstaller.php index fe70731..1a1fdb2 100644 --- a/src/Uninstaller.php +++ b/src/Uninstaller.php @@ -7,19 +7,21 @@ namespace LIT\MCP; defined('ABSPATH') || exit; /** - * Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check - * transient, the log-cleanup cron hook and the change log table – on every site of a - * network – and the Protokoll tab's per-user screen option. + * Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check and + * Divi-release transients, the log-cleanup cron hook and the change log table – on every + * site of a network – and the Protokoll tab's per-user screen option. * * Deliberately self-contained (no reference to any other LIT\MCP\* class): uninstall.php * is executed by WordPress core standalone, without loading the plugin's main file first, * so the Composer/Jetpack autoloader is not guaranteed to have run. The option/hook/table * names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION, - * Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Admin\ConnectionStatus:: - * ENDPOINT_TRANSIENT, ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE and - * Admin\LogTab::PER_PAGE_OPTION on - * purpose; tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in - * sync with those constants. + * Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\ + * ReleaseStore::OPTION, Integrations\Divi\Release\CheckReportStore::OPTION, Integrations\ + * Divi\Release\DiviCheckRoute::LOCK, Admin\ConnectionStatus::ENDPOINT_TRANSIENT, + * Integrations\Divi\Release\ReleaseResolver::CACHE, ChangeLog\ChangeLogCleanup::HOOK, + * ChangeLog\ChangeLog::TABLE and Admin\LogTab::PER_PAGE_OPTION on purpose; + * tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with + * those constants. * * Never touches the bundled wp-media/mcp-oauth library's data (option mcp_jwt_secret, * option wpmedia_mcp_oauth_rewrite_version, user meta mcp_refresh_jti_*, application @@ -28,8 +30,11 @@ defined('ABSPATH') || exit; */ final class Uninstaller { - private const OPTIONS = ['lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version']; - private const TRANSIENT = 'lit_mcp_endpoint_check'; + private const OPTIONS = [ + 'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version', + 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock', + ]; + private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state']; private const CRON_HOOK = 'lit_mcp_log_cleanup'; private const TABLE = 'lit_mcp_log'; @@ -61,7 +66,9 @@ final class Uninstaller foreach (self::OPTIONS as $option) { delete_option($option); } - delete_transient(self::TRANSIENT); + foreach (self::TRANSIENTS as $transient) { + delete_transient($transient); + } wp_clear_scheduled_hook(self::CRON_HOOK); $table = $wpdb->prefix . self::TABLE; diff --git a/tests/Stubs/WpRestResponse.php b/tests/Stubs/WpRestResponse.php new file mode 100644 index 0000000..e410bae --- /dev/null +++ b/tests/Stubs/WpRestResponse.php @@ -0,0 +1,32 @@ + $headers */ + public function __construct(public mixed $data = null, public int $status = 200, public array $headers = []) + { + } + + public function get_status(): int + { + return $this->status; + } + + /** @return array */ + public function get_headers(): array + { + return $this->headers; + } + } +} + +if (! class_exists('WP_REST_Response')) { + class WP_REST_Response extends WP_HTTP_Response + { + } +} diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php new file mode 100644 index 0000000..3e347ec --- /dev/null +++ b/tests/Unit/Admin/DiviTabTest.php @@ -0,0 +1,213 @@ +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 ''; + }); + 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', $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); + } +} diff --git a/tests/Unit/Admin/SettingsPageTest.php b/tests/Unit/Admin/SettingsPageTest.php index 23314c5..848a723 100644 --- a/tests/Unit/Admin/SettingsPageTest.php +++ b/tests/Unit/Admin/SettingsPageTest.php @@ -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> */ + 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'], + ]; + } } diff --git a/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php b/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php new file mode 100644 index 0000000..8c5cd4a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php @@ -0,0 +1,93 @@ +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(); + } +} diff --git a/tests/Unit/UninstallTest.php b/tests/Unit/UninstallTest.php index a370a91..87aba14 100644 --- a/tests/Unit/UninstallTest.php +++ b/tests/Unit/UninstallTest.php @@ -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 + ); } } diff --git a/tests/Unit/UninstallerTest.php b/tests/Unit/UninstallerTest.php index df5f23b..edb9038 100644 --- a/tests/Unit/UninstallerTest.php +++ b/tests/Unit/UninstallerTest.php @@ -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 + ); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index fa16d19..d8242fd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -14,6 +14,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/Stubs/WpError.php'; require_once __DIR__ . '/Stubs/WpUser.php'; require_once __DIR__ . '/Stubs/WpRestRequest.php'; +require_once __DIR__ . '/Stubs/WpRestResponse.php'; require_once __DIR__ . '/Stubs/Wpdb.php'; require_once __DIR__ . '/Stubs/WpPost.php'; require_once __DIR__ . '/Stubs/WpTerm.php'; diff --git a/uninstall.php b/uninstall.php index 04abb0e..e68a9d5 100644 --- a/uninstall.php +++ b/uninstall.php @@ -4,8 +4,10 @@ * Uninstall WP-LIT-MCP: removes its own data only, on every site of a network. * * Removed: options lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version, - * transient lit_mcp_endpoint_check, cron hook lit_mcp_log_cleanup, table {prefix}lit_mcp_log, user meta - * lit_mcp_log_per_page (Protokoll screen option, removed once for all users). + * lit_mcp_divi_releases, lit_mcp_divi_check_report, lit_mcp_divi_check_lock, transients + * lit_mcp_endpoint_check and lit_mcp_divi_state, cron hook lit_mcp_log_cleanup, table + * {prefix}lit_mcp_log, user meta lit_mcp_log_per_page (Protokoll screen option, removed once + * for all users). * * Kept on purpose: data of the bundled wp-media/mcp-oauth library (option mcp_jwt_secret, * option wpmedia_mcp_oauth_rewrite_version, user meta mcp_refresh_jti_*, application passwords