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());
}
}
}