From 6c59934caa9f0c7ebe9551f90b3e5580a534f9db Mon Sep 17 00:00:00 2001
From: Jan-Niclas Loosen
Date: Sun, 18 Oct 2026 21:53:04 +0200
Subject: [PATCH] =?UTF-8?q?refactor:=20Divi-Boot-Schritte=20in=20DiviInteg?=
=?UTF-8?q?ration::boot()=20(Bootable)=20=E2=80=93=20Upgrader-Hook,=20Pr?=
=?UTF-8?q?=C3=BCf-Route,=20CLI=20und=20Reiter=20=E2=80=9EDivi=E2=80=9C=20?=
=?UTF-8?q?=C3=BCber=20lit=5Fmcp=5Fadmin=5Ftabs;=20IntegrationRegistry::bo?=
=?UTF-8?q?ot()=20bootet=20jede=20Integration=20genau=20einmal;=20Pr=C3=BC?=
=?UTF-8?q?fseiten-Aufr=C3=A4umen=20im=20load-Hook=20statt=20beim=20Render?=
=?UTF-8?q?n?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
src/Admin/DiviTab.php | 36 ++++++++-
src/Admin/SettingsPage.php | 46 ++++++++----
src/Integrations/Bootable.php | 18 +++++
src/Integrations/Divi/DiviIntegration.php | 32 +++++++-
src/Integrations/IntegrationRegistry.php | 21 ++++++
src/Plugin.php | 18 +----
tests/Unit/Admin/DiviTabTest.php | 74 ++++++++++++++++++-
tests/Unit/Admin/SettingsPageTest.php | 18 +++++
.../Integrations/Divi/DiviIntegrationTest.php | 39 ++++++++++
.../Integrations/IntegrationRegistryTest.php | 28 +++++++
10 files changed, 293 insertions(+), 37 deletions(-)
create mode 100644 src/Integrations/Bootable.php
diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php
index cb1176f..3fdff5d 100644
--- a/src/Admin/DiviTab.php
+++ b/src/Admin/DiviTab.php
@@ -23,7 +23,8 @@ defined('ABSPATH') || exit;
/**
* Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST,
* DiviCheckRoute), "Divi 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.
+ * for a database release). Loading the tab removes check pages a dead check left behind – in the page's load hook
+ * (removeOrphans()), not while rendering.
*/
final class DiviTab implements AdminTab
{
@@ -31,6 +32,9 @@ final class DiviTab implements AdminTab
public const RELEASE_ACTION = 'lit_mcp_divi_release';
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';
+ /** @var list Check pages removeOrphans() deleted in this request; render() reports them. */
+ private array $removed = [];
+
/**
* @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
* @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey().
@@ -63,6 +67,22 @@ final class DiviTab implements AdminTab
{
add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
+ add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'removeOrphans']);
+ }
+
+ /**
+ * load- of the settings page, before any output: on this tab, for manage_options and with Divi active,
+ * deletes the check pages a dead check left behind (WriteProbe::removeOrphans(): marked drafts with the check
+ * title, older than ten minutes – no running check uses them). On a GET without nonce on purpose: it removes
+ * only the plugin's own leftovers and is idempotent. render() only reports what was deleted.
+ */
+ public function removeOrphans(): void
+ {
+ if (! $this->requested() || ! current_user_can('manage_options') || $this->version() === '') {
+ return;
+ }
+
+ $this->removed = WriteProbe::removeOrphans();
}
public function release(): void
@@ -127,7 +147,6 @@ final class DiviTab implements AdminTab
return;
}
- $removed = WriteProbe::removeOrphans();
$status = ReleaseGate::status();
$report = $this->reports()->latest();
@@ -137,13 +156,13 @@ final class DiviTab implements AdminTab
'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 !== []) {
+ if ($this->removed !== []) {
printf(
'
%s
',
esc_html(sprintf(
/* translators: %s: comma separated page IDs */
__('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
- implode(', ', $removed)
+ implode(', ', $this->removed)
))
);
}
@@ -305,6 +324,15 @@ final class DiviTab implements AdminTab
) . '';
}
+ /** Whether this request opens this tab of the settings page (the load hook fires for every tab). */
+ private function requested(): bool
+ {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
+ $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
+
+ return $tab === self::SLUG;
+ }
+
private function version(): string
{
return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php
index b81246f..8bb705a 100644
--- a/src/Admin/SettingsPage.php
+++ b/src/Admin/SettingsPage.php
@@ -16,24 +16,27 @@ final class SettingsPage
/** Hook suffix WordPress gives this page (add_options_page → "settings_page_"). */
public const SCREEN_HOOK = 'settings_page_' . self::SLUG;
- /** @var array */
- private array $tabs = [];
+ /**
+ * Filter on the tab list (list): integrations append their own tabs in Bootable::boot() – Divi its
+ * tab "Divi". Applied once, on first use of the tabs (after Plugin booted the integrations).
+ */
+ public const TABS_FILTER = 'lit_mcp_admin_tabs';
+
+ /** @var array|null null = TABS_FILTER not applied yet. */
+ private ?array $tabs = null;
/**
- * @param list $tabs The first tab is the default.
+ * @param list $baseTabs The plugin's own tabs; the first tab is the default.
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
* @param string $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
* @param string $version Asset version for cache busting.
*/
public function __construct(
- array $tabs,
+ private array $baseTabs,
private string $logoUrl = '',
private string $assetsUrl = '',
private string $version = ''
) {
- foreach ($tabs as $tab) {
- $this->tabs[$tab->slug()] = $tab;
- }
}
public function register(): void
@@ -41,7 +44,7 @@ final class SettingsPage
add_action('admin_menu', [$this, 'addMenu']);
add_action('admin_init', [$this, 'registerSettings']);
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
- foreach ($this->tabs as $tab) {
+ foreach ($this->tabs() as $tab) {
$tab->registerActions();
}
}
@@ -59,7 +62,7 @@ final class SettingsPage
public function registerSettings(): void
{
- foreach ($this->tabs as $tab) {
+ foreach ($this->tabs() as $tab) {
$tab->registerSettings();
}
}
@@ -90,7 +93,7 @@ final class SettingsPage
/** @return list */
public function tabSlugs(): array
{
- return array_keys($this->tabs);
+ return array_keys($this->tabs());
}
public function currentTab(): string
@@ -98,7 +101,9 @@ final class SettingsPage
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
$requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
- return isset($this->tabs[$requested]) ? $requested : (string) array_key_first($this->tabs);
+ $tabs = $this->tabs();
+
+ return isset($tabs[$requested]) ? $requested : (string) array_key_first($tabs);
}
/** @param array $args */
@@ -123,7 +128,7 @@ final class SettingsPage
echo '';
$this->renderNotice();
printf('';
- $this->tabs[$current]->render();
+ $this->tabs()[$current]->render();
echo '';
}
+ /** @return array The plugin's tabs and those the integrations add (TABS_FILTER), by slug. */
+ private function tabs(): array
+ {
+ if ($this->tabs === null) {
+ $this->tabs = [];
+ foreach ((array) apply_filters(self::TABS_FILTER, $this->baseTabs) as $tab) {
+ if ($tab instanceof AdminTab) {
+ $this->tabs[$tab->slug()] = $tab;
+ }
+ }
+ }
+
+ return $this->tabs;
+ }
+
private function renderLogo(): void
{
if ($this->logoUrl === '') {
diff --git a/src/Integrations/Bootable.php b/src/Integrations/Bootable.php
new file mode 100644
index 0000000..514cd27
--- /dev/null
+++ b/src/Integrations/Bootable.php
@@ -0,0 +1,18 @@
+ CompatibilityCheck::create($this->contentComposer());
+ (new DiviCheckRoute($check))->register();
+ DiviCheckCommand::register($check);
+
+ add_filter(SettingsPage::TABS_FILTER, fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log)]);
+ }
+
public function abilities(): array
{
$loader = $this->loader();
diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php
index 28f30cd..2a59956 100644
--- a/src/Integrations/IntegrationRegistry.php
+++ b/src/Integrations/IntegrationRegistry.php
@@ -16,11 +16,32 @@ final class IntegrationRegistry
/** @var list}>|null Available integrations with their abilities. */
private ?array $loaded = null;
+ private bool $booted = false;
+
public function __construct(Integration ...$integrations)
{
$this->integrations = array_values($integrations);
}
+ /**
+ * Boots every Bootable integration once, in registry order – available or not: Plugin boots on plugins_loaded,
+ * before the theme (Divi) is loaded, so isAvailable() is not known yet (as the hooks were registered before
+ * Bootable). A second call does nothing.
+ */
+ public function boot(): void
+ {
+ if ($this->booted) {
+ return;
+ }
+
+ $this->booted = true;
+ foreach ($this->integrations as $integration) {
+ if ($integration instanceof Bootable) {
+ $integration->boot();
+ }
+ }
+ }
+
/** @return list */
public function available(): array
{
diff --git a/src/Plugin.php b/src/Plugin.php
index 40212ab..174c78d 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -12,7 +12,6 @@ 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;
@@ -21,15 +20,11 @@ use LIT\MCP\Admin\ToolsTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
-use LIT\MCP\Cli\DiviCheckCommand;
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;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Media\MediaIntegration;
@@ -78,11 +73,6 @@ final class Plugin
(new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, $limits))->register();
- // Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
- add_action('upgrader_process_complete', static function (): void {
- delete_transient(ReleaseResolver::CACHE);
- });
-
$schema = self::contentSchema();
$registry = null;
$policy = null;
@@ -139,10 +129,9 @@ 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();
- DiviCheckCommand::register($diviCheck);
+ // Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
+ // page reads its tabs.
+ $registry->boot();
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
(new AbilityRegistrar(
@@ -167,7 +156,6 @@ 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/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php
index 705ae9c..d5e0ba4 100644
--- a/tests/Unit/Admin/DiviTabTest.php
+++ b/tests/Unit/Admin/DiviTabTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
+use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\AdminPostAction;
use LIT\MCP\Admin\DiviTab;
@@ -14,10 +15,12 @@ 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\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
+use WP_Post;
final class DiviTabTest extends TestCase
{
@@ -41,6 +44,13 @@ final class DiviTabTest extends TestCase
Functions\when('add_query_arg')->alias(
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
);
+ Functions\when('sanitize_key')->alias(static fn (string $key): string => strtolower($key));
+ }
+
+ protected function tearDown(): void
+ {
+ unset($_GET['tab']);
+ parent::tearDown();
}
private static function report(bool $green = true, string $version = '5.3.0', string $key = 'key'): CheckReport
@@ -143,10 +153,9 @@ final class DiviTabTest extends TestCase
}
/** @return string The rendered tab. */
- private function rendered(ReleaseStatus $status, ?CheckReport $report): string
+ private function rendered(ReleaseStatus $status, ?CheckReport $report, ?DiviTab $tab = null): 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('');
@@ -158,7 +167,7 @@ final class DiviTabTest extends TestCase
Functions\when('get_option')->justReturn('d.m.Y');
ob_start();
- $this->tab($report, Mockery::mock(ReleaseStore::class))->render();
+ ($tab ?? $this->tab($report, Mockery::mock(ReleaseStore::class)))->render();
return (string) ob_get_clean();
}
@@ -254,4 +263,63 @@ final class DiviTabTest extends TestCase
self::assertStringContainsString('Theme Builder: Nachbau von x() – y', $html);
self::assertStringContainsString('Plugin-Update', $html);
}
+
+ /** The tab removes the pages of a dead check in the settings page's load hook, never while rendering. */
+ public function testRegistersTheCleanupOnTheSettingsPageLoadHook(): void
+ {
+ Actions\expectAdded('admin_post_lit_mcp_divi_release')->once();
+ Actions\expectAdded('admin_post_lit_mcp_divi_withdraw')->once();
+ Actions\expectAdded('load-settings_page_lit-mcp')->once()->with(Mockery::type('array'));
+
+ $this->tab(null, Mockery::mock(ReleaseStore::class))->registerActions();
+ }
+
+ public function testTheLoadHookOnThisTabRemovesOrphansAndTheTabReportsThem(): void
+ {
+ $_GET['tab'] = 'divi';
+ Functions\expect('get_posts')->once()->andReturn([
+ new WP_Post(['ID' => 51, 'post_title' => WriteProbe::TITLE . ' 2026-10-18 08:00']),
+ ]);
+ Functions\expect('wp_delete_post')->once()->with(51, true)->andReturn(new WP_Post(['ID' => 51]));
+ $tab = $this->tab(self::report(), Mockery::mock(ReleaseStore::class));
+
+ $tab->removeOrphans();
+ $html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report(), $tab);
+
+ self::assertStringContainsString('Liegengebliebene Prüfseiten gelöscht: 51', $html);
+ }
+
+ public function testRenderingAloneDeletesNothing(): void
+ {
+ Functions\expect('get_posts')->never();
+ Functions\expect('wp_delete_post')->never();
+
+ $html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report());
+
+ self::assertStringNotContainsString('Liegengebliebene Prüfseiten', $html);
+ }
+
+ public function testTheLoadHookDeletesNothingOnOtherTabsWithoutTheRightOrWithoutDivi(): void
+ {
+ Functions\expect('get_posts')->never();
+ $tab = $this->tab(null, Mockery::mock(ReleaseStore::class));
+
+ unset($_GET['tab']);
+ $tab->removeOrphans();
+ $_GET['tab'] = 'tools';
+ $tab->removeOrphans();
+
+ $_GET['tab'] = 'divi';
+ Functions\when('current_user_can')->justReturn(false);
+ $tab->removeOrphans();
+
+ Functions\when('current_user_can')->justReturn(true);
+ (new DiviTab(
+ Mockery::mock(CheckReportStore::class),
+ Mockery::mock(ReleaseStore::class),
+ FakeChangeLog::forbidden(),
+ null,
+ static fn (): string => ''
+ ))->removeOrphans();
+ }
}
diff --git a/tests/Unit/Admin/SettingsPageTest.php b/tests/Unit/Admin/SettingsPageTest.php
index 0cea111..c913ae4 100644
--- a/tests/Unit/Admin/SettingsPageTest.php
+++ b/tests/Unit/Admin/SettingsPageTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
+use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Admin\SettingsPage;
@@ -58,6 +59,23 @@ final class SettingsPageTest extends TestCase
self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs());
}
+ /** Integrations add their tabs in Bootable::boot() (Divi: "Divi", last); the filter runs once, on first use. */
+ public function testIntegrationsAppendTheirTabsThroughTheFilter(): void
+ {
+ $divi = Mockery::mock(AdminTab::class);
+ $divi->allows('slug')->andReturn('divi');
+ $divi->expects('registerActions')->once();
+ Filters\expectApplied(SettingsPage::TABS_FILTER)->once()->andReturnUsing(
+ static fn (array $tabs): array => [...$tabs, $divi]
+ );
+ $page = new SettingsPage([$this->tab('verbindung', 'Verbindung'), $this->tab('zugriff', 'Zugriff')]);
+ Functions\when('add_action')->justReturn(true);
+
+ $page->register();
+
+ self::assertSame(['verbindung', 'zugriff', 'divi'], $page->tabSlugs());
+ }
+
public function testRendersTheLogoTopRightOnlyWhenGiven(): void
{
Functions\when('current_user_can')->justReturn(true);
diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
index 4b9e911..7ecc1f3 100644
--- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
+++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
@@ -4,14 +4,21 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
+use Brain\Monkey\Actions;
+use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
+use LIT\MCP\Admin\AdminTab;
+use LIT\MCP\Admin\DiviTab;
+use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Integrations\Divi\DiviBuilderDetector;
use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\DiviLogoUsage;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
+use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
+use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Media\MediaUsageSource;
use LIT\MCP\Integrations\WordPress\BuilderDetector;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
@@ -251,4 +258,36 @@ final class DiviIntegrationTest extends TestCase
self::assertSame(1, $asked);
self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
}
+
+ /** Bootable: Divi's own hooks – upgrader cache reset, check route, tab "Divi" after the plugin's tabs. */
+ public function testBootRegistersDivisOwnHooks(): void
+ {
+ $upgraded = null;
+ $tabs = null;
+ Actions\expectAdded('upgrader_process_complete')->once()->whenHappen(
+ static function (callable $callback) use (&$upgraded): void {
+ $upgraded = $callback;
+ }
+ );
+ Actions\expectAdded('rest_api_init')->once()->with(Mockery::on(
+ static fn (array $callback): bool => $callback[0] instanceof DiviCheckRoute
+ ));
+ Filters\expectAdded(SettingsPage::TABS_FILTER)->once()->whenHappen(
+ static function (callable $callback) use (&$tabs): void {
+ $tabs = $callback;
+ }
+ );
+ Functions\expect('delete_transient')->once()->with(ReleaseResolver::CACHE)->andReturn(true);
+ $base = Mockery::mock(AdminTab::class);
+
+ (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->boot();
+ self::assertIsCallable($upgraded);
+ $upgraded();
+ self::assertIsCallable($tabs);
+ $all = $tabs([$base]);
+
+ self::assertCount(2, $all);
+ self::assertSame($base, $all[0]);
+ self::assertInstanceOf(DiviTab::class, $all[1]);
+ }
}
diff --git a/tests/Unit/Integrations/IntegrationRegistryTest.php b/tests/Unit/Integrations/IntegrationRegistryTest.php
index 2e05f90..20bc4d9 100644
--- a/tests/Unit/Integrations/IntegrationRegistryTest.php
+++ b/tests/Unit/Integrations/IntegrationRegistryTest.php
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
+use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Integration;
@@ -79,6 +80,33 @@ final class IntegrationRegistryTest extends TestCase
self::assertSame('Divi', (new DiviIntegration($schema, FakeChangeLog::forbidden()))->label());
}
+ /** Integration-owned boot steps run once each, in registry order, whether or not the integration is available. */
+ public function testBootsEveryBootableIntegrationOnce(): void
+ {
+ $booted = [];
+ $bootable = function (string $id, bool $available) use (&$booted): Integration {
+ $integration = Mockery::mock(Integration::class . ', ' . Bootable::class);
+ $integration->allows('id')->andReturn($id);
+ $integration->allows('isAvailable')->andReturn($available);
+ $integration->expects('abilities')->never();
+ $integration->expects('boot')->once()->andReturnUsing(static function () use (&$booted, $id): void {
+ $booted[] = $id;
+ });
+
+ return $integration;
+ };
+ $registry = new IntegrationRegistry(
+ $this->integration('core', true, []),
+ $bootable('divi', false),
+ $bootable('pods', true)
+ );
+
+ $registry->boot();
+ $registry->boot();
+
+ self::assertSame(['divi', 'pods'], $booted);
+ }
+
private function ability(string $name, bool $direct): Ability
{
$ability = Mockery::mock(Ability::class);