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(
+ '',
+ 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 '';
+ }
+
+ 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('';
+
+ 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('';
+ }
+
+ private function renderReport(CheckReport $report): void
+ {
+ printf(
+ '%s %s
',
+ esc_html__('Letzte Prüfung', 'wp-lit-mcp'),
+ esc_html(sprintf(
+ /* translators: 1: Divi version, 2: plugin version, 3: date */
+ __('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'),
+ $report->diviVersion,
+ $report->pluginVersion,
+ date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
+ ))
+ );
+ foreach ($report->steps as $step) {
+ $mark = $step->skipped ? '–' : ($step->green() ? '✓' : '✗');
+ printf('%s %s', esc_html($mark), esc_html($step->label()));
+ if ($step->skipped) {
+ echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
+ }
+ if ($step->problems !== []) {
+ echo '';
+ foreach ($step->problems as $problem) {
+ echo '' . esc_html($problem) . ' ';
+ }
+ echo ' ';
+ }
+ echo ' ';
+ }
+ echo ' ' . 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 '' . $text . ' ';
+ });
+ 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