feat(admin): Prüfroute je Profil und Reiter „Compatibility“

Die generische CheckRoute (POST lit-mcp/v1/<id>-check, für Divi weiter /divi-check) ersetzt DiviCheckRoute: Lock je Profil mit Besitzer-Token, Bericht je Profil, Rücksprung auf den neuen Reiter und Hook lit_mcp_compat_changed (Divi lädt darauf seinen Freigabe-Status neu). Der Reiter „Compatibility“ zeigt je Plugin Zustand (Wort und Icon), Hinweis zur Stufe, Fähigkeiten, getestete und freigegebene Versionen und den letzten Bericht, mit Prüfen, Freigeben und Zurückziehen (Recht manage_options, Allowlist der Profil-ID, Nonce je Profil). Freigeben serverseitig nur mit frischem grünem Bericht. Der Load-Hook lit_mcp_compat_profile_load bereitet den Abbau des DiviTab vor, der bis dahin parallel läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 13:15:59 +01:00
co-authored by Claude Opus 5.5
parent 7d6a97a54e
commit 85f4138b36
22 changed files with 1940 additions and 334 deletions
+310
View File
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\CheckRoute;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class CheckRouteTest 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);
Functions\when('rest_url')->alias(static fn (string $path): string => 'https://k.de/wp-json/' . $path);
}
protected function tearDown(): void
{
unset($GLOBALS['wpdb']);
parent::tearDown();
}
private static function profile(string $id): Profile
{
return new Profile(id: $id, label: ucfirst($id), tier: Tier::LockOnFailure, capabilities: []);
}
/** @param (callable(Profile, int): CheckReport)|null $check */
private static function route(?callable $check = null, ?CheckReportStore $reports = null): CheckRoute
{
return new CheckRoute(
new Compatibility([self::profile('divi'), self::profile('pods')]),
$check === null ? static fn () => self::fail('no check expected') : $check(...),
static fn (Profile $profile): CheckReportStore => $reports ?? self::fail('no store expected')
);
}
private static function report(): CheckReport
{
return new CheckReport('r1', 'd', 1, '3.3.9.2', '0.6.0', 'k', []);
}
/** The "<time>:<token>" lock value of the last INSERT or takeover UPDATE the route issued. */
private static ?string $lockValue = null;
/** Remembers the lock value written by $sql ("VALUES (name, 'value', 'no')" or "SET option_value = 'value'"). */
private static function captureLockValue(string $sql): void
{
if (preg_match("/VALUES \\('[^']+', '([^']+)', 'no'\\)|SET option_value = '([^']+)'/", $sql, $m) === 1) {
self::$lockValue = ($m[2] ?? '') !== '' ? $m[2] : $m[1];
}
}
/** Expects the compare-and-delete of the lock the route wrote last: only while the value is still its own. */
private static function expectOwnLockDeleted(\wpdb $db, string $id = 'pods'): void
{
$db->expects('query')->once()->with(Mockery::on(static fn (string $sql): bool => $sql === sprintf(
"DELETE FROM wp_options WHERE option_name = 'lit_mcp_%s_check_lock' AND option_value = '%s'",
$id,
self::$lockValue
)))->andReturn(1);
}
/** Mock of the global $wpdb, its INSERT IGNORE for the lock of $id returning $inserted rows affected. */
private static function wpdb(int $inserted, string $id = 'pods'): \wpdb
{
self::$lockValue = null;
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->expects('query')->once()->with(Mockery::on(static function (string $sql) use ($id): bool {
$matches = str_starts_with(
$sql,
"INSERT IGNORE INTO wp_options (option_name, option_value, autoload) "
. "VALUES ('lit_mcp_{$id}_check_lock', '"
) && str_ends_with($sql, "', 'no')");
$matches && self::captureLockValue($sql);
return $matches;
}))->andReturn($inserted);
$GLOBALS['wpdb'] = $db;
return $db;
}
public function testRegistersOnePostRouteForAdministratorsOnlyPerProfile(): void
{
$registered = [];
Functions\expect('register_rest_route')->twice()->andReturnUsing(
static function (string $ns, string $route, array $args) use (&$registered): 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);
$registered[$ns . $route] = $args['methods'] === 'POST' && $allowed === true
&& ($args['permission_callback'])() === false;
return true;
}
);
self::route()->registerRoutes();
self::assertSame(['lit-mcp/v1/divi-check' => true, 'lit-mcp/v1/pods-check' => true], $registered);
}
public function testTheUrlOfAProfile(): void
{
self::assertSame('https://k.de/wp-json/lit-mcp/v1/pods-check', CheckRoute::url('pods'));
}
public function testRunsTheCheckStoresTheReportRedirectsToTheTabAndAnnouncesTheChange(): void
{
$db = self::wpdb(1);
$report = self::report();
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->with($report);
self::expectOwnLockDeleted($db);
Actions\expectDone('lit_mcp_compat_changed')->once()->with('pods');
$route = self::route(static function (Profile $profile, int $user) use ($report): CheckReport {
self::assertSame('pods', $profile->id);
self::assertSame(1, $user);
return $report;
}, $reports);
$response = $route->handle('pods');
$location = $response->get_headers()['Location'];
self::assertSame(303, $response->get_status());
self::assertStringContainsString('tab=compatibility', $location);
self::assertStringContainsString('lit_mcp_notice=compat_checked', $location);
self::assertStringContainsString('lit_mcp_profile=pods', $location);
}
public function testTheLockIsPerProfile(): void
{
$db = self::wpdb(1, 'divi');
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save');
self::expectOwnLockDeleted($db, 'divi');
Actions\expectDone('lit_mcp_compat_changed')->once()->with('divi');
self::route(static fn (): CheckReport => self::report(), $reports)->handle('divi');
}
public function testASecondCheckWaitsForTheFirst(): void
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn((string) time());
Actions\expectDone('lit_mcp_compat_changed')->never();
$response = self::route()->handle('pods');
$location = $response->get_headers()['Location'];
self::assertStringContainsString('lit_mcp_notice=compat_check_running', $location);
self::assertStringContainsString('lit_mcp_profile=pods', $location);
}
public function testAStaleLockIsTakenOver(): void
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn('1');
$db->expects('query')->once()->with(Mockery::on(static function (string $sql): bool {
$matches = str_starts_with($sql, "UPDATE wp_options SET option_value = '")
&& str_ends_with(
$sql,
"' WHERE option_name = 'lit_mcp_pods_check_lock' AND option_value = '1'"
);
$matches && self::captureLockValue($sql);
return $matches;
}))->andReturn(1);
self::expectOwnLockDeleted($db);
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save');
Actions\expectDone('lit_mcp_compat_changed')->once();
$response = self::route(static fn (): CheckReport => self::report(), $reports)->handle('pods');
self::assertStringContainsString('lit_mcp_notice=compat_checked', $response->get_headers()['Location']);
}
public function testATakeoverLostToAnotherRequestWaitsForIt(): void
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn('1');
$db->expects('query')->once()->with(Mockery::on(static fn (string $sql): bool => str_starts_with(
$sql,
"UPDATE wp_options SET option_value = '"
)))->andReturn(0);
$response = self::route()->handle('pods');
self::assertStringContainsString('lit_mcp_notice=compat_check_running', $response->get_headers()['Location']);
}
public function testTheLockIsReleasedAndTheCheckMarkedFailedWhenItThrows(): void
{
$db = self::wpdb(1);
self::expectOwnLockDeleted($db);
Actions\expectDone('lit_mcp_compat_changed')->never();
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->never();
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$response = self::route(static function (): never {
throw new RuntimeException('pods kaputt');
}, $reports)->handle('pods');
} finally {
ini_set('error_log', (string) $previous);
}
self::assertStringContainsString('lit_mcp_notice=compat_check_failed', $response->get_headers()['Location']);
self::assertStringContainsString(
'pods check failed: RuntimeException: pods kaputt',
(string) file_get_contents($log)
);
unlink($log);
}
public function testTheLockValueCarriesTimeAndAnOwnerToken(): void
{
$db = self::wpdb(1);
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save');
self::expectOwnLockDeleted($db);
Actions\expectDone('lit_mcp_compat_changed');
self::route(static fn (): CheckReport => self::report(), $reports)->handle('pods');
self::assertMatchesRegularExpression('/^\d+:[0-9a-f]{8}$/', (string) self::$lockValue);
}
public function testACheckThatOutlivedItsLockDoesNotDeleteTheTakersLock(): void
{
$db = self::wpdb(1);
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save');
Actions\expectDone('lit_mcp_compat_changed');
$deleted = [];
// Check A runs so long that B takes the row over: the row then holds B's value, not A's.
$db->expects('query')->once()->with(Mockery::on(
static function (string $sql) use (&$deleted): bool {
$deleted[] = $sql;
return str_starts_with($sql, 'DELETE');
}
))->andReturn(0);
self::route(static fn (): CheckReport => self::report(), $reports)->handle('pods');
self::assertCount(1, $deleted);
self::assertStringEndsWith(
"AND option_value = '" . self::$lockValue . "'",
$deleted[0],
'the DELETE must name the owner token, so a taken-over row (other value) survives'
);
}
public function testAThrowingChangeListenerDoesNotTurnTheStoredReportIntoAnError(): void
{
$db = self::wpdb(1);
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save');
self::expectOwnLockDeleted($db);
Actions\expectDone('lit_mcp_compat_changed')->once()->andThrow(new RuntimeException('listener kaputt'));
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$response = self::route(static fn (): CheckReport => self::report(), $reports)->handle('pods');
} finally {
ini_set('error_log', (string) $previous);
}
self::assertSame(303, $response->get_status());
self::assertStringContainsString('lit_mcp_notice=compat_checked', $response->get_headers()['Location']);
self::assertStringContainsString('listener kaputt', (string) file_get_contents($log));
unlink($log);
}
public function testAnUnknownProfileIsRefusedBeforeAnythingHappens(): void
{
$GLOBALS['wpdb'] = Mockery::mock(\wpdb::class)->makePartial();
Actions\expectDone('lit_mcp_compat_changed')->never();
foreach (['nope', '', 'pods_check_lock', "pods' OR 1=1 --"] as $id) {
$response = self::route()->handle($id);
self::assertSame(404, $response->get_status());
self::assertArrayNotHasKey('Location', $response->get_headers());
}
}
}
+742
View File
@@ -0,0 +1,742 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Admin\AdminPostAction;
use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class CompatibilityTabTest extends TestCase
{
private const CAPS = [FakeCapability::Read, FakeCapability::Write];
private ?string $redirect = null;
/** @var list<string> The nonce actions check_admin_referer() was asked for. */
private array $nonces = [];
protected function setUp(): void
{
parent::setUp();
$this->redirect = null;
$this->nonces = [];
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('current_user_can')->justReturn(true);
Functions\when('check_admin_referer')->alias(function (string $action): int {
$this->nonces[] = $action;
return 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)
);
Functions\when('rest_url')->alias(static fn (string $path): string => 'https://k.de/wp-json/' . $path);
Functions\when('wp_create_nonce')->alias(static fn (string $action): string => 'nonce-' . $action);
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
});
Functions\when('submit_button')->alias(
static function (
string $text,
string $type = 'primary',
string $name = 'submit',
bool $wrap = true,
array $attributes = []
): void {
$extra = '';
foreach ($attributes as $key => $value) {
$extra .= ' ' . $key . '="' . esc_attr($value) . '"'; // WordPress escapes the values itself
}
echo '<button class="button-' . $type . '" name="' . $name . '"' . $extra . '>' . $text . '</button>';
}
);
Functions\when('get_option')->alias(static fn (string $name): string => $name === 'date_format' ? 'Y-m-d' : '');
Functions\when('date_i18n')->alias(static fn (string $format, int $time): string => gmdate($format, $time));
Functions\when('get_userdata')->justReturn(false);
Functions\when('sanitize_key')->alias(
static fn (string $key): string => strtolower((string) preg_replace('/[^a-z0-9_\-]/i', '', $key))
);
Functions\when('wp_unslash')->returnArg();
}
protected function tearDown(): void
{
unset($_POST['profile'], $_GET['tab']);
parent::tearDown();
}
/** A profile of the invented plugin "fake"; $matrix only for ReleasePerVersion. */
private static function profile(
Tier $tier = Tier::LockOnFailure,
?CompatibilityMatrix $matrix = null,
bool $active = true,
?string $version = '1.2.0',
array $tested = [],
?Closure $stepLabel = null
): Profile {
return new Profile(
id: 'fake',
label: 'Fake <Plugin>',
tier: $tier,
capabilities: self::CAPS,
active: static fn (): bool => $active,
version: static fn (): ?string => $version,
matrix: $tier === Tier::ReleasePerVersion
? static fn (): CompatibilityMatrix => $matrix ?? self::fail('no matrix expected')
: null,
testedVersions: $tested,
stateKey: static fn (string $v): string => 'key' . $v,
stepLabel: $stepLabel
);
}
private static function matrix(ReleaseState $state, array $problems = []): CompatibilityMatrix
{
return CompatibilityMatrix::uniform(self::CAPS, $state, $problems, null);
}
private static function report(bool $green = true, string $version = '1.2.0', string $key = 'key1.2.0'): CheckReport
{
$steps = [
new CheckStep(CheckStep::MANIFEST, $green ? [] : ['Funktion <i>x</i> fehlt'], false, 'Manifest <b>Label</b>'),
new CheckStep('old', [], false),
];
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps);
}
private function tab(
Profile $profile,
?CheckReport $report = null,
?ReleaseStore $releases = null,
?FakeChangeLog $log = null
): CompatibilityTab {
$reports = Mockery::mock(CheckReportStore::class);
$reports->allows('latest')->andReturn($report);
$store = $releases ?? Mockery::mock(ReleaseStore::class);
if ($releases === null) {
$store->allows('entries')->andReturn([]);
$store->allows('find')->andReturn(null);
}
return new CompatibilityTab(
new Compatibility([$profile]),
$log ?? FakeChangeLog::forbidden(),
new AdminPostAction(static function (): void {
}),
static fn (): CheckReportStore => $reports,
static fn (): ReleaseStore => $store
);
}
private static function html(CompatibilityTab $tab): string
{
ob_start();
$tab->render();
return (string) ob_get_clean();
}
public function testItIsTheTabCompatibility(): void
{
$tab = $this->tab(self::profile());
self::assertInstanceOf(AdminTab::class, $tab);
self::assertSame('compatibility', $tab->slug());
self::assertSame('Compatibility', $tab->label());
}
public function testAnInactiveProfileIsOneLineAndNeverAsksForTheMatrix(): void
{
Actions\expectDone('lit_mcp_compat_profile_extra')->never();
// ReleasePerVersion with a matrix closure that fails the test when called.
$html = self::html($this->tab(self::profile(Tier::ReleasePerVersion, null, active: false)));
self::assertStringContainsString('nicht installiert oder nicht aktiv', $html);
self::assertStringContainsString('Fake &lt;Plugin&gt;', $html);
self::assertStringNotContainsString('<form', $html);
self::assertStringNotContainsString('<table', $html);
}
/** @dataProvider states */
public function testTheOverallStateIsShownAsWordAndIcon(ReleaseState $state, string $word, string $icon): void
{
$html = self::html($this->tab(self::profile(Tier::ReleasePerVersion, self::matrix($state, ['Problem A']))));
self::assertStringContainsString('<strong>' . $word . '</strong>', $html);
self::assertStringContainsString('dashicons-' . $icon, $html);
self::assertStringContainsString('aria-hidden="true"', $html);
}
/** @return array<string, array{ReleaseState, string, string}> */
public static function states(): array
{
return [
'released' => [ReleaseState::Released, 'freigegeben', 'yes-alt'],
'untested' => [ReleaseState::Untested, 'ungetestet', 'warning'],
'incompatible' => [ReleaseState::Incompatible, 'inkompatibel', 'dismiss'],
];
}
public function testItShowsTheVersionAndTheProblemsAndTheCapabilityTable(): void
{
$html = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Incompatible, ['Problem A']))
));
self::assertStringContainsString('1.2.0', $html);
self::assertStringContainsString('Problem A', $html);
self::assertStringContainsString('Fake fake-read', $html);
self::assertStringContainsString('Fake fake-write', $html);
}
/** @dataProvider tierTexts */
public function testTheTierHintSaysWhatTheReleaseMeans(Tier $tier, string $text): void
{
$matrix = $tier === Tier::ReleasePerVersion ? self::matrix(ReleaseState::Released) : null;
$html = self::html($this->tab(self::profile($tier, $matrix)));
self::assertStringContainsString($text, $html);
}
/** @return array<string, array{Tier, string}> */
public static function tierTexts(): array
{
return [
'release' => [Tier::ReleasePerVersion, 'Freigabe pro Version nötig'],
'lock' => [
Tier::LockOnFailure,
'Freigabe wird dokumentiert, Nutzung automatisch, solange die Prüfung grün ist',
],
'report' => [Tier::ReportOnly, 'nur Anzeige'],
];
}
public function testLockOnFailureIsLockedWhenTheProfileIsActiveButTooOld(): void
{
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
version: static fn (): string => '0.9',
minVersion: '1.0'
);
$tab = new CompatibilityTab(new Compatibility([$profile]), FakeChangeLog::forbidden());
Functions\when('get_option')->justReturn(false);
$html = self::html($tab);
self::assertStringContainsString('<strong>inkompatibel</strong>', $html);
self::assertStringContainsString('Mindestversion', $html);
}
public function testTestedAndReleasedVersionsAreListed(): void
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->allows('entries')->andReturn([
['version' => '1.1.0', 'plugin_version' => '0.7.0', 'user_id' => 1, 'released_at' => 'x', 'report_id' => 'r'],
]);
$releases->allows('find')->andReturn(null);
$html = self::html($this->tab(self::profile(tested: ['1.0.0', '1.0.1']), null, $releases));
self::assertStringContainsString('1.0.0, 1.0.1', $html);
self::assertStringContainsString('1.1.0', $html);
self::assertStringContainsString('LIT MCP 0.7.0', $html);
}
public function testNoVersionListsShowADashWhenEmpty(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('Getestete Versionen', $html);
self::assertStringContainsString('Freigegebene Versionen', $html);
self::assertSame(2, substr_count($html, '<td>–</td>'));
}
public function testTheLatestReportListsEveryStepWithIconAndLabel(): void
{
$profile = self::profile(stepLabel: static fn (CheckStep $s): string => $s->label ?? 'Alt:' . $s->key);
$html = self::html($this->tab($profile, self::report()));
self::assertStringContainsString('Letzte Prüfung', $html);
self::assertStringContainsString('Manifest &lt;b&gt;Label&lt;/b&gt;', $html);
self::assertStringContainsString('Alt:old', $html);
self::assertStringContainsString('<strong>bestanden</strong>', $html);
}
public function testAFailedStepSaysSoInVisibleText(): void
{
$html = self::html($this->tab(self::profile(), self::report(false)));
self::assertStringContainsString('<strong>fehlgeschlagen</strong>', $html);
}
public function testTheCapabilityTableHasACaptionNamingTheProfile(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString(
'<caption class="screen-reader-text">Fähigkeiten von Fake &lt;Plugin&gt;</caption>',
$html
);
}
public function testEveryButtonNamesItsProfileForAssistiveTechnology(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$withdrawable = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()))
. self::html($this->tab($withdrawable, self::report(), self::store(self::entry())));
self::assertStringContainsString('aria-label="Prüfung starten: Fake &lt;Plugin&gt;"', $html);
// The release label names the profile itself.
self::assertStringContainsString('>Fake <Plugin> 1.2.0 freigeben</button>', $html);
self::assertStringContainsString('aria-label="Freigabe zurückziehen: Fake &lt;Plugin&gt;"', $html);
}
public function testEachFormButtonHasAUniqueNameInsteadOfSubmit(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$withdrawable = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()))
. self::html($this->tab($withdrawable, self::report(), self::store(self::entry())));
self::assertStringNotContainsString('name="submit"', $html);
self::assertStringContainsString('name="lit-mcp-compat-release-fake"', $html);
self::assertStringContainsString('name="lit-mcp-compat-withdraw-fake"', $html);
self::assertStringContainsString('name="lit-mcp-compat-check-fake"', $html);
}
public function testTheLoadHookIsRegisteredOnTheSettingsPage(): void
{
$tab = $this->tab(self::profile());
Actions\expectAdded('load-settings_page_lit-mcp')->once()->with([$tab, 'loadProfiles']);
$tab->registerActions();
}
public function testTheLoadActionFiresPerProfileOnTheOwnTabOnly(): void
{
$_GET['tab'] = 'compatibility';
Actions\expectDone('lit_mcp_compat_profile_load')->once()->with('fake');
$this->tab(self::profile())->loadProfiles();
}
public function testTheLoadActionStaysQuietOnOtherTabsAndForOthersThanAdministrators(): void
{
Actions\expectDone('lit_mcp_compat_profile_load')->never();
foreach (['verbindung', '', ['compatibility']] as $tabParam) {
$_GET['tab'] = $tabParam;
$this->tab(self::profile())->loadProfiles();
}
unset($_GET['tab']);
$this->tab(self::profile())->loadProfiles();
$_GET['tab'] = 'compatibility';
Functions\when('current_user_can')->justReturn(false);
$this->tab(self::profile())->loadProfiles();
}
public function testRenderingDoesNotFireTheLoadAction(): void
{
$_GET['tab'] = 'compatibility';
Actions\expectDone('lit_mcp_compat_profile_load')->never();
self::html($this->tab(self::profile()));
}
public function testAnArrayAsProfileFieldIsRefusedWithoutAWarning(): void
{
$this->actionsDie();
$_POST['profile'] = ['fake'];
$this->expectException(\RuntimeException::class);
$this->tab(self::profile())->release();
}
public function testASkippedStepIsMarkedAsNotRun(): void
{
$report = new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '1.2.0', Plugin::VERSION, 'k', [
new CheckStep('write', [], true, 'Schreibtest'),
]);
$html = self::html($this->tab(self::profile(), $report));
self::assertStringContainsString('Schreibtest', $html);
self::assertStringContainsString('<strong>übersprungen</strong>', $html);
self::assertStringNotContainsString('screen-reader-text">übersprungen', $html);
self::assertStringContainsString('nicht ausgeführt', $html);
}
public function testAStepWithoutLabelAndWithoutLabelSourceShowsItsKey(): void
{
$html = self::html($this->tab(self::profile(), self::report()));
self::assertStringContainsString('</strong>: old', $html);
}
public function testAFailedStepListsItsProblems(): void
{
$html = self::html($this->tab(self::profile(), self::report(false)));
self::assertStringContainsString('fehlgeschlagen', $html);
self::assertStringContainsString('Funktion &lt;i&gt;x&lt;/i&gt; fehlt', $html);
}
public function testWithoutAReportNothingIsListed(): void
{
self::assertStringNotContainsString('Letzte Prüfung', self::html($this->tab(self::profile())));
}
public function testTheProfileExtraHookRunsLastWithTheProfileId(): void
{
Actions\expectDone('lit_mcp_compat_profile_extra')->once()->with('fake');
self::html($this->tab(self::profile(), self::report()));
}
public function testEverythingFromThirdPartiesIsEscaped(): void
{
$profile = self::profile(
Tier::ReleasePerVersion,
self::matrix(ReleaseState::Incompatible, ['<script>alert(1)</script>']),
version: '<b>x</b>',
tested: ['<i>t</i>']
);
$html = self::html($this->tab($profile, self::report(false, '<b>x</b>')));
self::assertStringNotContainsString('<script>', $html);
self::assertStringNotContainsString('<b>x</b>', $html);
self::assertStringNotContainsString('<i>t</i>', $html);
self::assertStringNotContainsString('<i>x</i>', $html);
self::assertStringNotContainsString('<b>Label</b>', $html);
self::assertStringContainsString('&lt;b&gt;x&lt;/b&gt;', $html);
self::assertStringContainsString('&lt;script&gt;alert(1)&lt;/script&gt;', $html);
}
// --- 1.21: forms and actions -------------------------------------------------------------------------------
private function actionsDie(): void
{
Functions\when('wp_die')->alias(static function (): never {
throw new \RuntimeException('died');
});
}
private static function entry(string $version = '1.2.0'): array
{
return [
'version' => $version,
'plugin_version' => Plugin::VERSION,
'user_id' => 1,
'released_at' => '2026-10-01T08:00:00+00:00',
'report_id' => 'r1',
];
}
/** A store with the given entries for the installed state; add()/withdraw() expectations are the test's. */
private static function store(?array $entry = null): ReleaseStore
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->allows('entries')->andReturn($entry === null ? [] : [$entry]);
$releases->allows('find')->andReturn($entry);
return $releases;
}
public function testRegistersTheAdminPostHandlers(): void
{
$tab = $this->tab(self::profile());
Actions\expectAdded('admin_post_lit_mcp_compat_release')->once()->with([$tab, 'release']);
Actions\expectAdded('admin_post_lit_mcp_compat_withdraw')->once()->with([$tab, 'withdraw']);
$tab->registerActions();
}
public function testTheCheckFormPostsToTheRestRouteWithTheRestNonce(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('action="https://k.de/wp-json/lit-mcp/v1/fake-check"', $html);
self::assertStringContainsString('name="_wpnonce" value="nonce-wp_rest"', $html);
self::assertStringContainsString('Prüfung starten', $html);
}
public function testReleaseNeedsAFreshGreenReportForTheInstalledState(): void
{
$_POST['profile'] = 'fake';
$reports = [null, self::report(false), self::report(true, '1.1.0', 'key1.1.0'), self::report(true, '1.2.0', 'old')];
foreach ($reports as $report) {
$this->redirect = null;
$releases = self::store();
$releases->expects('add')->never();
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab(self::profile(), $report, $releases)->release();
self::assertStringContainsString('lit_mcp_notice=compat_release_refused', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_profile=fake', (string) $this->redirect);
}
}
public function testReleaseStoresTheEntryLogsItAndAnnouncesTheChange(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('add')->once()->with('1.2.0', Plugin::VERSION, 1, 'r1');
$log = new FakeChangeLog();
Actions\expectDone('lit_mcp_compat_changed')->once()->with('fake');
$this->tab(self::profile(), self::report(), $releases, $log)->release();
self::assertSame(['lit_mcp_compat_release_fake'], $this->nonces);
self::assertStringContainsString('tab=compatibility', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_notice=compat_released', (string) $this->redirect);
self::assertSame('lit/fake-release', $log->records[0]['ability']);
self::assertSame('fake', $log->records[0]['object_type']);
self::assertSame('1.2.0', $log->records[0]['object_id']);
self::assertStringContainsString('Fake <Plugin> 1.2.0', $log->records[0]['summary']);
}
/** @dataProvider refusedReleases */
public function testReleaseIsRefusedWhereTheTabOffersNone(Profile $profile, ?array $entry): void
{
$_POST['profile'] = 'fake';
$releases = self::store($entry);
$releases->expects('add')->never();
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab($profile, self::report(), $releases)->release();
self::assertStringContainsString('lit_mcp_notice=compat_release_refused', (string) $this->redirect);
}
/** @return array<string, array{Profile, ?array}> */
public static function refusedReleases(): array
{
return [
'version tested in the plugin' => [self::profile(tested: ['1.2.0']), null],
'already released' => [self::profile(), self::entry()],
'report only' => [self::profile(Tier::ReportOnly), null],
'inactive' => [self::profile(active: false), null],
'version unknown' => [self::profile(version: null), null],
'per version, but already released' => [
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released)),
null,
],
'per version, but incompatible' => [
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Incompatible, ['x'])),
null,
],
];
}
public function testAReleasePerVersionProfileIsReleasedWhileUntested(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('add')->once();
Actions\expectDone('lit_mcp_compat_changed')->once();
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$this->tab($profile, self::report(), $releases, new FakeChangeLog())->release();
self::assertStringContainsString('lit_mcp_notice=compat_released', (string) $this->redirect);
}
public function testAnUnknownProfileIsRefusedBeforeTheNonceOrAnyStore(): void
{
$this->actionsDie();
Actions\expectDone('lit_mcp_compat_changed')->never();
$releases = self::store();
$releases->expects('add')->never();
$releases->expects('withdraw')->never();
foreach (['nope', '', "fake' OR 1=1"] as $id) {
$_POST['profile'] = $id;
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected the request to stop');
} catch (\RuntimeException $e) {
self::assertSame('died', $e->getMessage());
}
}
}
self::assertNull($this->redirect);
self::assertSame([], $this->nonces);
}
public function testAMissingProfileFieldIsRefused(): void
{
$this->actionsDie();
unset($_POST['profile']);
$this->expectException(\RuntimeException::class);
$this->tab(self::profile())->release();
}
public function testActionsNeedManageOptionsAndTheNonceOfTheProfile(): void
{
$_POST['profile'] = 'fake';
$this->actionsDie();
Functions\when('current_user_can')->justReturn(false);
$releases = self::store();
$releases->expects('add')->never();
$releases->expects('withdraw')->never();
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected 403');
} catch (\RuntimeException $e) {
self::assertSame('died', $e->getMessage());
}
}
// With the right but a failing nonce check (wrong nonce: WordPress dies) nothing is stored either.
Functions\when('current_user_can')->justReturn(true);
Functions\when('check_admin_referer')->alias(static function (): never {
throw new \RuntimeException('bad nonce');
});
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected the nonce to stop');
} catch (\RuntimeException $e) {
self::assertSame('bad nonce', $e->getMessage());
}
}
}
public function testWithdrawRemovesTheEntryOfTheInstalledVersionLogsItAndAnnouncesTheChange(): void
{
$_POST['profile'] = 'fake';
$releases = self::store(self::entry());
$releases->expects('withdraw')->once()->with('1.2.0', Plugin::VERSION)->andReturn(true);
$log = new FakeChangeLog();
Actions\expectDone('lit_mcp_compat_changed')->once()->with('fake');
$this->tab(self::profile(), null, $releases, $log)->withdraw();
self::assertSame(['lit_mcp_compat_withdraw_fake'], $this->nonces);
self::assertStringContainsString('lit_mcp_notice=compat_withdrawn', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_profile=fake', (string) $this->redirect);
self::assertSame('lit/fake-release', $log->records[0]['ability']);
}
public function testWithdrawWithoutAnEntryFails(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('withdraw')->once()->andReturn(false);
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab(self::profile(), null, $releases)->withdraw();
self::assertStringContainsString('lit_mcp_notice=compat_withdraw_failed', (string) $this->redirect);
}
public function testUntestedWithAFreshGreenReportOffersTheRelease(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$html = self::html($this->tab($profile, self::report()));
self::assertStringContainsString('name="action" value="lit_mcp_compat_release"', $html);
self::assertStringContainsString('name="profile" value="fake"', $html);
self::assertStringContainsString('value="nonce-lit_mcp_compat_release_fake"', $html);
self::assertStringContainsString('1.2.0 freigeben', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testUntestedWithoutAFreshReportAsksForACheckInsteadOfOfferingTheRelease(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
foreach ([null, self::report(false)] as $report) {
$html = self::html($this->tab($profile, $report));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringContainsString('Freigeben geht erst nach einer vollständig grünen Prüfung', $html);
}
}
public function testAReleasedPerVersionProfileOffersNeitherReleaseNorWithdrawal(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testLockOnFailureOffersTheReleaseForAVersionNotTestedInThePlugin(): void
{
$html = self::html($this->tab(self::profile(), self::report()));
self::assertStringContainsString('value="lit_mcp_compat_release"', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testATestedVersionAndReportOnlyOfferNoRelease(): void
{
foreach ([self::profile(tested: ['1.2.0']), self::profile(Tier::ReportOnly)] as $profile) {
$html = self::html($this->tab($profile, self::report()));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringNotContainsString('Freigeben geht erst', $html);
}
}
public function testAnExistingReleaseCanBeWithdrawnWhateverTheState(): void
{
foreach ([ReleaseState::Released, ReleaseState::Incompatible] as $state) {
$profile = self::profile(Tier::ReleasePerVersion, self::matrix($state, $state === ReleaseState::Released ? [] : ['x']));
$html = self::html($this->tab($profile, self::report(), self::store(self::entry())));
self::assertStringContainsString('value="lit_mcp_compat_withdraw"', $html);
self::assertStringContainsString('value="nonce-lit_mcp_compat_withdraw_fake"', $html);
self::assertStringNotContainsString('value="lit_mcp_compat_release"', $html);
}
}
}
+89 -6
View File
@@ -25,7 +25,7 @@ final class SettingsPageTest extends TestCase
protected function tearDown(): void
{
unset($_GET['tab'], $_GET['lit_mcp_notice']);
unset($_GET['tab'], $_GET['lit_mcp_notice'], $_GET['lit_mcp_profile']);
parent::tearDown();
}
@@ -195,9 +195,6 @@ final class SettingsPageTest extends TestCase
public static function diviNotices(): array
{
return [
['divi_checked', 'success', 'Divi-Prüfung abgeschlossen'],
['divi_check_running', 'warning', 'bereits eine Divi-Prüfung'],
['divi_check_failed', 'error', 'PHP-Error-Log'],
['divi_released', 'success', 'für LIT MCP freigegeben'],
['divi_release_refused', 'error', 'Prüfung erneut ausführen'],
['divi_withdrawn', 'success', 'Freigabe zurückgezogen'],
@@ -205,11 +202,96 @@ final class SettingsPageTest extends TestCase
];
}
public function testTheNoticeParameterIsRemovedFromTheAddressBarAfterwards(): void
/** @dataProvider compatNotices */
public function testCompatNoticesNameTheKnownProfile(string $key, string $class, string $text): void
{
Functions\when('current_user_can')->justReturn(true);
$_GET['lit_mcp_notice'] = $key;
$_GET['lit_mcp_profile'] = 'pods';
ob_start();
$this->pageWithProfiles()->render();
$html = (string) ob_get_clean();
self::assertStringContainsString('notice-' . $class, $html);
self::assertStringContainsString('Pods &lt;1&gt;: ', $html);
self::assertStringContainsString($text, $html);
}
/** @return list<list<string>> */
public static function compatNotices(): array
{
return [
['compat_checked', 'success', 'Prüfung abgeschlossen'],
['compat_check_running', 'warning', 'bereits eine Prüfung'],
['compat_check_failed', 'error', 'PHP-Error-Log'],
['compat_released', 'success', 'freigegeben'],
['compat_release_refused', 'error', 'Prüfung erneut ausführen'],
['compat_withdrawn', 'success', 'Freigabe zurückgezogen'],
['compat_withdraw_failed', 'error', 'keine Freigabe'],
];
}
public function testAnUnknownProfileInTheNoticeIsIgnored(): void
{
Functions\when('current_user_can')->justReturn(true);
$_GET['lit_mcp_notice'] = 'compat_checked';
$_GET['lit_mcp_profile'] = '<script>x</script>';
ob_start();
$this->pageWithProfiles()->render();
$html = (string) ob_get_clean();
self::assertStringContainsString('Prüfung abgeschlossen', $html);
self::assertStringNotContainsString('script', $html);
self::assertStringNotContainsString('Pods', $html);
}
/** The checks of all profiles end in compat_* notices; the old Divi-only check notices are gone. */
public function testTheRetiredDiviCheckNoticesShowNothing(): void
{
Functions\when('current_user_can')->justReturn(true);
foreach (['divi_checked', 'divi_check_running', 'divi_check_failed'] as $key) {
$_GET['lit_mcp_notice'] = $key;
ob_start();
$this->pageWithProfiles()->render();
self::assertStringNotContainsString('class="notice', (string) ob_get_clean(), $key);
}
}
public function testArrayValuesInTheQueryAreIgnoredWithoutAWarning(): void
{
Functions\when('current_user_can')->justReturn(true);
$_GET['tab'] = ['x'];
$_GET['lit_mcp_notice'] = ['compat_checked'];
$_GET['lit_mcp_profile'] = ['pods'];
ob_start();
$this->pageWithProfiles()->render();
$html = (string) ob_get_clean();
self::assertStringNotContainsString('class="notice', $html);
$_GET['lit_mcp_notice'] = 'compat_checked';
ob_start();
$this->pageWithProfiles()->render();
self::assertStringNotContainsString('Pods', (string) ob_get_clean());
}
private function pageWithProfiles(): SettingsPage
{
return new SettingsPage(
[$this->tab('verbindung', 'Verbindung')],
profileLabel: static fn (string $id): ?string => $id === 'pods' ? 'Pods <1>' : null
);
}
public function testTheNoticeParametersAreRemovedFromTheAddressBarAfterwards(): void
{
$page = new SettingsPage([$this->tab('verbindung', 'Verbindung')]);
self::assertSame(['a', 'lit_mcp_notice'], $page->removableQueryArgs(['a']));
self::assertSame(['a', 'lit_mcp_notice', 'lit_mcp_profile'], $page->removableQueryArgs(['a']));
}
public function testAddsAnOverviewHelpTab(): void
@@ -221,6 +303,7 @@ final class SettingsPageTest extends TestCase
static fn (array $tab): bool => $tab['id'] === 'lit-mcp-overview'
&& str_contains($tab['content'], 'Tab „Verbindung“')
&& str_contains($tab['content'], 'Tab „Tools“')
&& str_contains($tab['content'], 'Tab „Compatibility“')
));
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->helpTabFor($screen);
+6 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
@@ -57,10 +58,12 @@ final class ProfileTest extends TestCase
self::assertNull($bare->stateKey);
self::assertNull($bare->extraSteps);
self::assertNull($bare->manifestCheck);
self::assertNull($bare->stepLabel);
$stateKey = static fn (string $version): string => 'k' . $version;
$extraSteps = static fn (bool $manifestClean): array => [];
$manifestCheck = static fn (): ManifestCheck => new ManifestCheck([], signatures: false);
$stepLabel = static fn (CheckStep $step): string => 'L' . $step->key;
$full = new Profile(
id: 'fake',
label: 'Fake',
@@ -69,12 +72,14 @@ final class ProfileTest extends TestCase
testedVersions: ['1.0'],
stateKey: $stateKey,
extraSteps: $extraSteps,
manifestCheck: $manifestCheck
manifestCheck: $manifestCheck,
stepLabel: $stepLabel
);
self::assertSame(['1.0'], $full->testedVersions);
self::assertSame($stateKey, $full->stateKey);
self::assertSame($extraSteps, $full->extraSteps);
self::assertSame($manifestCheck, $full->manifestCheck);
self::assertSame($stepLabel, $full->stepLabel);
}
public function testAReleasePerVersionProfileWithoutMatrixIsAProgrammingError(): void
@@ -20,7 +20,6 @@ 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\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
@@ -325,9 +324,12 @@ final class DiviIntegrationTest extends TestCase
$upgraded = $callback;
}
);
Actions\expectAdded('rest_api_init')->once()->with(Mockery::on(
static fn (array $callback): bool => $callback[0] instanceof DiviCheckRoute
));
$changed = null;
Actions\expectAdded('lit_mcp_compat_changed')->once()->whenHappen(
static function (callable $callback) use (&$changed): void {
$changed = $callback;
}
);
Filters\expectAdded(SettingsPage::TABS_FILTER)->once()->whenHappen(
static function (callable $callback) use (&$tabs): void {
$tabs = $callback;
@@ -339,6 +341,10 @@ final class DiviIntegrationTest extends TestCase
(new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->boot();
self::assertIsCallable($upgraded);
$upgraded();
// The route announces a finished check; only Divi's own profile resets the release gate (once, no mirrors).
self::assertIsCallable($changed);
$changed('pods');
$changed('divi');
self::assertIsCallable($tabs);
$all = $tabs([$base]);
@@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
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);
}
protected function tearDown(): void
{
unset($GLOBALS['wpdb']);
parent::tearDown();
}
/** Mock of the global $wpdb, its INSERT IGNORE returning $inserted rows affected. */
private static function wpdb(int $inserted): \wpdb
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->expects('query')->once()->with(Mockery::on(static function (string $sql): bool {
return str_starts_with(
$sql,
"INSERT IGNORE INTO wp_options (option_name, option_value, autoload) "
. "VALUES ('lit_mcp_divi_check_lock', '"
) && str_ends_with($sql, "', 'no')");
}))->andReturn($inserted);
$GLOBALS['wpdb'] = $db;
return $db;
}
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
{
self::wpdb(1);
$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('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
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn((string) 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
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn('1');
$db->expects('query')->once()->with(Mockery::on(static function (string $sql): bool {
return str_starts_with($sql, "UPDATE wp_options SET option_value = '")
&& str_ends_with(
$sql,
"' WHERE option_name = 'lit_mcp_divi_check_lock' AND option_value = '1'"
);
}))->andReturn(1);
Functions\expect('delete_option')->once()->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');
$response = (new DiviCheckRoute(static fn () => $check, $reports))->handle();
self::assertStringContainsString('lit_mcp_notice=divi_checked', $response->get_headers()['Location']);
}
public function testATakeoverLostToAnotherRequestWaitsForIt(): void
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn('1');
$db->expects('query')->once()->with(Mockery::on(static fn (string $sql): bool => str_starts_with(
$sql,
"UPDATE wp_options SET option_value = '"
)))->andReturn(0);
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 testTheLockIsReleasedAndTheCheckMarkedFailedWhenItThrows(): void
{
self::wpdb(1);
Functions\expect('delete_option')->once()->with(DiviCheckRoute::LOCK);
$check = Mockery::mock(CompatibilityCheck::class);
$check->expects('run')->andThrow(new RuntimeException('divi kaputt'));
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->never();
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$response = (new DiviCheckRoute(static fn () => $check, $reports))->handle();
} finally {
ini_set('error_log', (string) $previous);
}
self::assertStringContainsString('lit_mcp_notice=divi_check_failed', $response->get_headers()['Location']);
self::assertStringContainsString(
'divi check failed: RuntimeException: divi kaputt',
(string) file_get_contents($log)
);
unlink($log);
}
}
@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\DiviReleases;
@@ -71,5 +73,9 @@ final class DiviProfileTest extends TestCase
self::assertSame(DiviReleases::TESTED, $profile->testedVersions);
self::assertSame(ReleaseResolver::stateKey('5.3.0'), ($profile->stateKey)('5.3.0'));
self::assertInstanceOf(ManifestCheck::class, ($profile->manifestCheck)());
// Reports saved before step labels were stored still get their Divi labels.
$old = new CheckStep(DiviCheckSteps::WRITE, []);
self::assertSame(DiviCheckSteps::label($old), ($profile->stepLabel)($old));
self::assertNotSame(DiviCheckSteps::WRITE, ($profile->stepLabel)($old));
}
}
+23 -2
View File
@@ -9,6 +9,7 @@ use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Compatibility\CapabilityGate;
use LIT\MCP\Compatibility\Compatibility;
@@ -52,11 +53,11 @@ final class PluginTest extends TestCase
);
}
/** Only the Divi integration has boot steps; a second boot registers nothing twice. */
/** Only the Divi integration has boot steps (upgrader hook, compat listener, tab); a second boot registers nothing twice. */
public function testTheBootableIntegrationsBootExactlyOnce(): void
{
Actions\expectAdded('upgrader_process_complete')->once();
Actions\expectAdded('rest_api_init')->once();
Actions\expectAdded('lit_mcp_compat_changed')->once();
Filters\expectAdded(SettingsPage::TABS_FILTER)->once();
$registry = self::registry();
@@ -137,6 +138,26 @@ final class PluginTest extends TestCase
self::assertInstanceOf(CapabilityGate::class, $guards[1]);
}
/** The tab "Compatibility" gets the registry's Compatibility: one section per profile, Divi and Pods. */
public function testTheCompatibilityTabShowsEveryProfileOfTheRegistry(): void
{
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
$theme = Mockery::mock();
$theme->allows('get_template')->andReturn('twentytwentyfive');
Functions\when('wp_get_theme')->justReturn($theme);
$tab = Plugin::compatibilityTab(self::registry(), FakeChangeLog::forbidden());
ob_start();
$tab->render();
$html = (string) ob_get_clean();
self::assertInstanceOf(CompatibilityTab::class, $tab);
self::assertSame('compatibility', $tab->slug());
self::assertStringContainsString('id="lit-mcp-compat-divi"', $html);
self::assertStringContainsString('id="lit-mcp-compat-pods"', $html);
}
private static function readOnly(object $source): bool
{
return (new ReflectionProperty($source, 'readOnly'))->getValue($source);
+1 -2
View File
@@ -11,7 +11,6 @@ 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\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Limits;
use LIT\MCP\Updater\RecheckOnInstall;
@@ -54,7 +53,7 @@ final class UninstallTest extends TestCase
$own = [
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', DiviCheckRoute::LOCK,
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
+1 -2
View File
@@ -12,7 +12,6 @@ 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\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Limits;
use LIT\MCP\Uninstaller;
@@ -60,7 +59,7 @@ final class UninstallerTest extends TestCase
self::assertSame(
[
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', DiviCheckRoute::LOCK,
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',