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:
co-authored by
Claude Opus 5.5
parent
7d6a97a54e
commit
85f4138b36
@@ -32,10 +32,14 @@ final class AdminPostAction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redirects to settings tab $slug with "lit_mcp_notice" = $notice and ends the request. */
|
/**
|
||||||
public function redirect(string $slug, string $notice): void
|
* Redirects to settings tab $slug with "lit_mcp_notice" = $notice (and further query $args) and ends the request.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $args
|
||||||
|
*/
|
||||||
|
public function redirect(string $slug, string $notice, array $args = []): void
|
||||||
{
|
{
|
||||||
wp_safe_redirect(SettingsPage::url($slug, ['lit_mcp_notice' => $notice]));
|
wp_safe_redirect(SettingsPage::url($slug, ['lit_mcp_notice' => $notice] + $args));
|
||||||
($this->terminate)();
|
($this->terminate)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Compatibility\CheckReport;
|
||||||
|
use LIT\MCP\Compatibility\CheckReportStore;
|
||||||
|
use LIT\MCP\Compatibility\Compatibility;
|
||||||
|
use LIT\MCP\Compatibility\Profile;
|
||||||
|
use LIT\MCP\Log;
|
||||||
|
use Throwable;
|
||||||
|
use WP_REST_Response;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Prüfung starten" of the tab "Compatibility" (spec §6.1): POST /wp-json/lit-mcp/v1/<profile>-check, one route per
|
||||||
|
* profile. A REST request on purpose – not admin-post – so the check runs in the same context as MCP calls (Divi
|
||||||
|
* renders its blocks in REST requests, lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce
|
||||||
|
* (cookie authentication); only manage_options. The profile comes from the route, which exists only for the
|
||||||
|
* profiles of Compatibility; handle() looks the ID up again, so an ID never reaches an option name unchecked.
|
||||||
|
* One check per profile at a time: an atomic "INSERT IGNORE" on the options table (lit_mcp_<id>_check_lock) is the
|
||||||
|
* lock – add_option() reads then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests
|
||||||
|
* could both "acquire" it. The value is "<time>:<token>". A lock older than LOCK_TTL is a dead
|
||||||
|
* check's, taken over with a conditional UPDATE (compare-and-swap on the value just read) so only one of several
|
||||||
|
* concurrent takers wins; the end of a check deletes the row only while it still holds its own token, so a check
|
||||||
|
* that outlived its lock does not free the taker's. A check that throws still releases the lock (finally) and
|
||||||
|
* redirects with compat_check_failed (logged, not an unhandled 500).
|
||||||
|
* Always answers 303 back to the tab "Compatibility" – every check, whatever the profile, lands there – and
|
||||||
|
* announces a stored report with the action lit_mcp_compat_changed($id); a listener that throws is logged and does
|
||||||
|
* not change the answer.
|
||||||
|
*/
|
||||||
|
final class CheckRoute
|
||||||
|
{
|
||||||
|
public const NAMESPACE = 'lit-mcp/v1';
|
||||||
|
public const CHANGED_ACTION = 'lit_mcp_compat_changed';
|
||||||
|
|
||||||
|
private const LOCK_TTL = 600;
|
||||||
|
|
||||||
|
/** @var Closure(Profile, int): CheckReport */
|
||||||
|
private Closure $check;
|
||||||
|
/** @var Closure(Profile): CheckReportStore */
|
||||||
|
private Closure $reports;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (Closure(Profile, int): CheckReport)|null $check Default: Compatibility::check().
|
||||||
|
* @param (Closure(Profile): CheckReportStore)|null $reports Default: CheckReportStore::forProfile().
|
||||||
|
*/
|
||||||
|
public function __construct(private Compatibility $compatibility, ?Closure $check = null, ?Closure $reports = null)
|
||||||
|
{
|
||||||
|
$this->check = $check ?? $compatibility->check(...);
|
||||||
|
$this->reports = $reports ?? CheckReportStore::forProfile(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_action('rest_api_init', [$this, 'registerRoutes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerRoutes(): void
|
||||||
|
{
|
||||||
|
foreach ($this->compatibility->profiles() as $profile) {
|
||||||
|
register_rest_route(self::NAMESPACE, self::route($profile->id), [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => fn (): WP_REST_Response => $this->handle($profile->id),
|
||||||
|
'permission_callback' => static fn (): bool => current_user_can('manage_options'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The URL of the profile's route; the caller passes an ID of a known profile. */
|
||||||
|
public static function url(string $profileId): string
|
||||||
|
{
|
||||||
|
return rest_url(self::NAMESPACE . self::route($profileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(string $profileId): WP_REST_Response
|
||||||
|
{
|
||||||
|
$profile = $this->compatibility->profile($profileId);
|
||||||
|
if ($profile === null) {
|
||||||
|
return new WP_REST_Response(null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$notice = 'compat_check_running';
|
||||||
|
$token = self::lock($profile->id);
|
||||||
|
if ($token !== null) {
|
||||||
|
try {
|
||||||
|
($this->reports)($profile)->save(($this->check)($profile, get_current_user_id()));
|
||||||
|
$notice = 'compat_checked';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::failure($profile->id . ' check', $e);
|
||||||
|
$notice = 'compat_check_failed';
|
||||||
|
} finally {
|
||||||
|
self::release($profile->id, $token);
|
||||||
|
}
|
||||||
|
if ($notice === 'compat_checked') {
|
||||||
|
// The report is stored: a listener that throws is logged, it does not turn the success into a 500.
|
||||||
|
try {
|
||||||
|
do_action(self::CHANGED_ACTION, $profile->id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::failure($profile->id . ' ' . self::CHANGED_ACTION, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(
|
||||||
|
null,
|
||||||
|
303,
|
||||||
|
['Location' => SettingsPage::url(
|
||||||
|
CompatibilityTab::SLUG,
|
||||||
|
['lit_mcp_notice' => $notice, 'lit_mcp_profile' => $profile->id]
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function route(string $profileId): string
|
||||||
|
{
|
||||||
|
return '/' . $profileId . '-check';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lockOption(string $profileId): string
|
||||||
|
{
|
||||||
|
return 'lit_mcp_' . $profileId . '_check_lock';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string|null The lock value "<time>:<token>" now held by this request (its owner token), null = busy. */
|
||||||
|
private static function lock(string $profileId): ?string
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$value = time() . ':' . bin2hex(random_bytes(4));
|
||||||
|
if (self::insertLock($wpdb, $profileId, $value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache.
|
||||||
|
$since = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
|
||||||
|
self::lockOption($profileId)
|
||||||
|
));
|
||||||
|
if ($since === null) {
|
||||||
|
// The lock was released between our failed insert and this read; try once more.
|
||||||
|
return self::insertLock($wpdb, $profileId, $value) ? $value : null;
|
||||||
|
}
|
||||||
|
if (time() - (int) $since < self::LOCK_TTL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$taken = $wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
|
||||||
|
$value,
|
||||||
|
self::lockOption($profileId),
|
||||||
|
$since
|
||||||
|
));
|
||||||
|
|
||||||
|
return (int) $taken === 1 ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare-and-delete: a check that outlived its lock (taken over meanwhile) must not free the taker's. */
|
||||||
|
private static function release(string $profileId, string $token): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
|
||||||
|
self::lockOption($profileId),
|
||||||
|
$token
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */
|
||||||
|
private static function insertLock(\wpdb $wpdb, string $profileId, string $value): bool
|
||||||
|
{
|
||||||
|
$inserted = $wpdb->query($wpdb->prepare(
|
||||||
|
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
|
||||||
|
self::lockOption($profileId),
|
||||||
|
$value
|
||||||
|
));
|
||||||
|
|
||||||
|
return (int) $inserted === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Compatibility\CheckReport;
|
||||||
|
use LIT\MCP\Compatibility\CheckReportStore;
|
||||||
|
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;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab "Compatibility": one section per third-party plugin of Compatibility::profiles() – its version, overall state
|
||||||
|
* (word and icon, never colour alone), what the tier means for a release, the capabilities, tested and released
|
||||||
|
* versions, the latest check report and "Prüfung starten" (Admin\CheckRoute). A profile that is not active is one
|
||||||
|
* line and never asks for its matrix. Everything that comes from a third-party plugin (version, problem texts, step
|
||||||
|
* labels) is escaped on output. The action lit_mcp_compat_profile_extra($id) closes a section: profiles add their
|
||||||
|
* own notes there; the action lit_mcp_compat_profile_load($id) (see loadProfiles()) runs before any output.
|
||||||
|
*/
|
||||||
|
final class CompatibilityTab implements AdminTab
|
||||||
|
{
|
||||||
|
public const SLUG = 'compatibility';
|
||||||
|
public const EXTRA_ACTION = 'lit_mcp_compat_profile_extra';
|
||||||
|
public const LOAD_ACTION = 'lit_mcp_compat_profile_load';
|
||||||
|
/** admin-post actions; the nonce action of a request is the action plus "_<profile ID>". */
|
||||||
|
public const RELEASE_ACTION = 'lit_mcp_compat_release';
|
||||||
|
public const WITHDRAW_ACTION = 'lit_mcp_compat_withdraw';
|
||||||
|
|
||||||
|
/** @var Closure(Profile): CheckReportStore */
|
||||||
|
private Closure $reports;
|
||||||
|
/** @var Closure(Profile): ReleaseStore */
|
||||||
|
private Closure $releases;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (Closure(Profile): CheckReportStore)|null $reports Default: CheckReportStore::forProfile().
|
||||||
|
* @param (Closure(Profile): ReleaseStore)|null $releases Default: ReleaseStore::forProfile().
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private Compatibility $compatibility,
|
||||||
|
private ?ChangeLog $log = null,
|
||||||
|
private ?AdminPostAction $action = null,
|
||||||
|
?Closure $reports = null,
|
||||||
|
?Closure $releases = null
|
||||||
|
) {
|
||||||
|
$this->reports = $reports ?? CheckReportStore::forProfile(...);
|
||||||
|
$this->releases = $releases ?? ReleaseStore::forProfile(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slug(): string
|
||||||
|
{
|
||||||
|
return self::SLUG;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Compatibility', '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']);
|
||||||
|
add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'loadProfiles']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* load-<page> of the settings page, before any output: on this tab and for manage_options announces each profile
|
||||||
|
* with lit_mcp_compat_profile_load($id), so a profile can do its housekeeping (and prepare a notice for
|
||||||
|
* its extra section) before render(). Nothing else is needed from the AdminTab contract: a tab hooks load-<page>
|
||||||
|
* itself in registerActions(), which SettingsPage already calls for every tab.
|
||||||
|
*/
|
||||||
|
public function loadProfiles(): void
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
|
||||||
|
$tab = is_string($_GET['tab'] ?? null) ? sanitize_key(wp_unslash($_GET['tab'])) : '';
|
||||||
|
if ($tab !== self::SLUG || ! current_user_can('manage_options')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->compatibility->profiles() as $profile) {
|
||||||
|
do_action(self::LOAD_ACTION, $profile->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the installed version of the posted profile: only as offered by the tab (see releasable()) and only
|
||||||
|
* after a fresh green report. The profile ID is looked up in Compatibility first (nothing else reaches option
|
||||||
|
* names, ability names or the redirect); the nonce belongs to that profile.
|
||||||
|
*/
|
||||||
|
public function release(): void
|
||||||
|
{
|
||||||
|
$action = $this->action ?? new AdminPostAction();
|
||||||
|
$action->guard();
|
||||||
|
$profile = $this->postedProfile();
|
||||||
|
check_admin_referer(self::RELEASE_ACTION . '_' . $profile->id);
|
||||||
|
|
||||||
|
$version = $this->compatibility->version($profile) ?? '';
|
||||||
|
$report = ($this->reports)($profile)->latest();
|
||||||
|
if (
|
||||||
|
$report === null
|
||||||
|
|| ! $this->releasable($profile, $version)
|
||||||
|
|| ! $report->freshFor($version, Plugin::VERSION, $this->stateKey($profile, $version))
|
||||||
|
) {
|
||||||
|
$action->redirect(self::SLUG, 'compat_release_refused', ['lit_mcp_profile' => $profile->id]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
($this->releases)($profile)->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
|
||||||
|
$this->log?->record(
|
||||||
|
'lit/' . $profile->id . '-release',
|
||||||
|
$profile->id,
|
||||||
|
$version,
|
||||||
|
sprintf(
|
||||||
|
'%s %s für LIT MCP %s freigegeben (Prüfbericht %s).',
|
||||||
|
$profile->label,
|
||||||
|
$version,
|
||||||
|
Plugin::VERSION,
|
||||||
|
$report->id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
do_action(CheckRoute::CHANGED_ACTION, $profile->id);
|
||||||
|
$action->redirect(self::SLUG, 'compat_released', ['lit_mcp_profile' => $profile->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withdraw(): void
|
||||||
|
{
|
||||||
|
$action = $this->action ?? new AdminPostAction();
|
||||||
|
$action->guard();
|
||||||
|
$profile = $this->postedProfile();
|
||||||
|
check_admin_referer(self::WITHDRAW_ACTION . '_' . $profile->id);
|
||||||
|
|
||||||
|
$version = $this->compatibility->version($profile) ?? '';
|
||||||
|
if (! ($this->releases)($profile)->withdraw($version, Plugin::VERSION)) {
|
||||||
|
$action->redirect(self::SLUG, 'compat_withdraw_failed', ['lit_mcp_profile' => $profile->id]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log?->record(
|
||||||
|
'lit/' . $profile->id . '-release',
|
||||||
|
$profile->id,
|
||||||
|
$version,
|
||||||
|
sprintf('Freigabe von %s %s zurückgezogen.', $profile->label, $version)
|
||||||
|
);
|
||||||
|
do_action(CheckRoute::CHANGED_ACTION, $profile->id);
|
||||||
|
$action->redirect(self::SLUG, 'compat_withdrawn', ['lit_mcp_profile' => $profile->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The profile named by the POST field "profile"; anything Compatibility does not know ends the request (400). */
|
||||||
|
private function postedProfile(): Profile
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- only a lookup key; the nonce is checked with it.
|
||||||
|
$id = is_string($_POST['profile'] ?? null) ? sanitize_key(wp_unslash($_POST['profile'])) : '';
|
||||||
|
$profile = $this->compatibility->profile($id);
|
||||||
|
if ($profile === null) {
|
||||||
|
wp_die(esc_html__('Unbekanntes Profil.', 'wp-lit-mcp'), '', ['response' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the tab offers: an active, versioned profile that releases at all (not ReportOnly), whose version the
|
||||||
|
* plugin does not list as tested, that has no release yet and – ReleasePerVersion – is still untested.
|
||||||
|
*/
|
||||||
|
private function releasable(Profile $profile, string $version): bool
|
||||||
|
{
|
||||||
|
return $version !== ''
|
||||||
|
&& $profile->tier !== Tier::ReportOnly
|
||||||
|
&& $this->compatibility->active($profile)
|
||||||
|
&& ! in_array($version, $profile->testedVersions, true)
|
||||||
|
&& ($this->releases)($profile)->find($version, Plugin::VERSION) === null
|
||||||
|
&& ($profile->tier !== Tier::ReleasePerVersion
|
||||||
|
|| $this->compatibility->matrix($profile)->state() === ReleaseState::Untested);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stateKey(Profile $profile, string $version): string
|
||||||
|
{
|
||||||
|
return $profile->stateKey === null ? '' : ($profile->stateKey)($version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
foreach ($this->compatibility->profiles() as $profile) {
|
||||||
|
$this->renderProfile($profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderProfile(Profile $profile): void
|
||||||
|
{
|
||||||
|
printf('<section class="lit-mcp-compat" id="lit-mcp-compat-%s">', esc_attr($profile->id));
|
||||||
|
printf('<h2>%s</h2>', esc_html($profile->label));
|
||||||
|
if (! $this->compatibility->active($profile)) {
|
||||||
|
echo '<p>' . esc_html__('nicht installiert oder nicht aktiv', 'wp-lit-mcp') . '</p></section>';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matrix = $this->compatibility->matrix($profile);
|
||||||
|
$problems = $this->compatibility->problems($profile);
|
||||||
|
$state = $matrix->state();
|
||||||
|
|
||||||
|
echo '<table class="form-table" role="presentation"><tbody>';
|
||||||
|
printf(
|
||||||
|
'<tr><th scope="row">%s</th><td>%s</td></tr>',
|
||||||
|
esc_html__('Version', 'wp-lit-mcp'),
|
||||||
|
esc_html($this->compatibility->version($profile) ?? __('nicht erkannt', 'wp-lit-mcp'))
|
||||||
|
);
|
||||||
|
printf(
|
||||||
|
'<tr><th scope="row">%s</th><td>%s – %s</td></tr>',
|
||||||
|
esc_html__('Gesamtzustand', 'wp-lit-mcp'),
|
||||||
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in stateBadge().
|
||||||
|
self::stateBadge($state),
|
||||||
|
esc_html(self::explanation($state))
|
||||||
|
);
|
||||||
|
if ($problems !== []) {
|
||||||
|
echo '<tr><th scope="row">' . esc_html__('Probleme', 'wp-lit-mcp') . '</th><td>';
|
||||||
|
self::renderList($problems);
|
||||||
|
echo '</td></tr>';
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<tr><th scope="row">%s</th><td>%s</td></tr>',
|
||||||
|
esc_html__('Freigabe', 'wp-lit-mcp'),
|
||||||
|
esc_html(self::tierHint($profile->tier))
|
||||||
|
);
|
||||||
|
printf(
|
||||||
|
'<tr><th scope="row">%s</th><td>%s</td></tr>',
|
||||||
|
esc_html__('Getestete Versionen', 'wp-lit-mcp'),
|
||||||
|
$profile->testedVersions === [] ? '–' : esc_html(implode(', ', $profile->testedVersions))
|
||||||
|
);
|
||||||
|
printf(
|
||||||
|
'<tr><th scope="row">%s</th><td>%s</td></tr>',
|
||||||
|
esc_html__('Freigegebene Versionen', 'wp-lit-mcp'),
|
||||||
|
esc_html($this->releasedVersions($profile)) ?: '–'
|
||||||
|
);
|
||||||
|
echo '</tbody></table>';
|
||||||
|
|
||||||
|
$this->renderCapabilities($profile, $matrix);
|
||||||
|
$report = ($this->reports)($profile)->latest();
|
||||||
|
$this->renderForms($profile, $report);
|
||||||
|
if ($report !== null) {
|
||||||
|
$this->renderReport($profile, $report);
|
||||||
|
}
|
||||||
|
do_action(self::EXTRA_ACTION, $profile->id);
|
||||||
|
echo '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderCapabilities(Profile $profile, CompatibilityMatrix $matrix): void
|
||||||
|
{
|
||||||
|
$headings = [__('Fähigkeit', 'wp-lit-mcp'), __('Zustand', 'wp-lit-mcp')];
|
||||||
|
echo '<table class="widefat striped lit-mcp-divi-features"><caption class="screen-reader-text">'
|
||||||
|
. esc_html(sprintf(
|
||||||
|
/* translators: %s: plugin name */
|
||||||
|
__('Fähigkeiten von %s', 'wp-lit-mcp'),
|
||||||
|
$profile->label
|
||||||
|
))
|
||||||
|
. '</caption><thead><tr>';
|
||||||
|
foreach ($headings as $heading) {
|
||||||
|
echo '<th scope="col">' . esc_html($heading) . '</th>';
|
||||||
|
}
|
||||||
|
echo '</tr></thead><tbody>';
|
||||||
|
foreach ($profile->capabilities as $capability) {
|
||||||
|
// data-label: on phones each row becomes a card and the cell names itself.
|
||||||
|
printf(
|
||||||
|
'<tr><td data-label="%s">%s</td><td data-label="%s">%s</td></tr>',
|
||||||
|
esc_attr($headings[0]),
|
||||||
|
esc_html($capability->label()),
|
||||||
|
esc_attr($headings[1]),
|
||||||
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in stateBadge().
|
||||||
|
self::stateBadge($matrix->of($capability)->state)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderForms(Profile $profile, ?CheckReport $report): void
|
||||||
|
{
|
||||||
|
$version = $this->compatibility->version($profile) ?? '';
|
||||||
|
$fresh = $report !== null && $report->freshFor($version, Plugin::VERSION, $this->stateKey($profile, $version));
|
||||||
|
$releasable = $this->releasable($profile, $version);
|
||||||
|
|
||||||
|
printf('<form method="post" action="%s">', esc_url(CheckRoute::url($profile->id)));
|
||||||
|
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
|
||||||
|
// The check is the one thing to do while a release is due without a usable report.
|
||||||
|
submit_button(
|
||||||
|
__('Prüfung starten', 'wp-lit-mcp'),
|
||||||
|
$releasable && ! $fresh ? 'primary' : 'secondary',
|
||||||
|
'lit-mcp-compat-check-' . $profile->id,
|
||||||
|
false,
|
||||||
|
['aria-label' => self::namedFor($profile, __('Prüfung starten', 'wp-lit-mcp'))]
|
||||||
|
);
|
||||||
|
echo '<p class="description">' . esc_html__('Die Prüfung dauert einige Sekunden.', 'wp-lit-mcp') . '</p>';
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
if ($releasable && $fresh) {
|
||||||
|
$this->actionForm(
|
||||||
|
$profile,
|
||||||
|
self::RELEASE_ACTION,
|
||||||
|
'release',
|
||||||
|
/* translators: 1: plugin name, 2: version */
|
||||||
|
sprintf(__('%1$s %2$s freigeben', 'wp-lit-mcp'), $profile->label, $version),
|
||||||
|
'primary'
|
||||||
|
);
|
||||||
|
} elseif ($releasable) {
|
||||||
|
echo '<p>' . esc_html__(
|
||||||
|
'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Version – Prüfung erneut ausführen.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A release applies whatever the overall state: one incompatible capability must not hide the way back.
|
||||||
|
if ($version !== '' && ($this->releases)($profile)->find($version, Plugin::VERSION) !== null) {
|
||||||
|
$this->actionForm(
|
||||||
|
$profile,
|
||||||
|
self::WITHDRAW_ACTION,
|
||||||
|
'withdraw',
|
||||||
|
__('Freigabe zurückziehen', 'wp-lit-mcp'),
|
||||||
|
'secondary',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "<visible text>: <profile>" – the accessible name starts with the visible text (WCAG 2.5.3). */
|
||||||
|
private static function namedFor(Profile $profile, string $label): string
|
||||||
|
{
|
||||||
|
return $label . ': ' . $profile->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $kind Short name of the action; with the profile ID it makes the button's name/id unique
|
||||||
|
* on a page that shows several profiles.
|
||||||
|
* @param bool $nameIt The label does not name the profile: add it to the accessible name.
|
||||||
|
*/
|
||||||
|
private function actionForm(
|
||||||
|
Profile $profile,
|
||||||
|
string $action,
|
||||||
|
string $kind,
|
||||||
|
string $label,
|
||||||
|
string $type,
|
||||||
|
bool $nameIt = false
|
||||||
|
): void {
|
||||||
|
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
||||||
|
printf('<input type="hidden" name="action" value="%s">', esc_attr($action));
|
||||||
|
printf('<input type="hidden" name="profile" value="%s">', esc_attr($profile->id));
|
||||||
|
wp_nonce_field($action . '_' . $profile->id);
|
||||||
|
submit_button(
|
||||||
|
$label,
|
||||||
|
$type,
|
||||||
|
'lit-mcp-compat-' . $kind . '-' . $profile->id,
|
||||||
|
false,
|
||||||
|
$nameIt ? ['aria-label' => self::namedFor($profile, $label)] : []
|
||||||
|
);
|
||||||
|
echo '</form>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderReport(Profile $profile, CheckReport $report): void
|
||||||
|
{
|
||||||
|
printf(
|
||||||
|
'<h3>%s</h3><p>%s</p><ul class="lit-mcp-divi-report">',
|
||||||
|
esc_html__('Letzte Prüfung', 'wp-lit-mcp'),
|
||||||
|
esc_html(sprintf(
|
||||||
|
/* translators: 1: plugin version, 2: LIT MCP version, 3: date */
|
||||||
|
__('Version %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'),
|
||||||
|
$report->version,
|
||||||
|
$report->pluginVersion,
|
||||||
|
date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
foreach ($report->steps as $step) {
|
||||||
|
[$icon, $word] = match (true) {
|
||||||
|
$step->skipped => ['minus', __('übersprungen', 'wp-lit-mcp')],
|
||||||
|
$step->green() => ['yes-alt', __('bestanden', 'wp-lit-mcp')],
|
||||||
|
default => ['dismiss', __('fehlgeschlagen', 'wp-lit-mcp')],
|
||||||
|
};
|
||||||
|
printf(
|
||||||
|
'<li class="lit-mcp-state lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s"'
|
||||||
|
. ' aria-hidden="true"></span> <strong>%2$s</strong>: %3$s',
|
||||||
|
esc_attr($icon),
|
||||||
|
esc_html($word),
|
||||||
|
esc_html($profile->stepLabel === null ? $step->label() : ($profile->stepLabel)($step))
|
||||||
|
);
|
||||||
|
if ($step->skipped) {
|
||||||
|
echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
|
||||||
|
}
|
||||||
|
if ($step->problems !== []) {
|
||||||
|
self::renderList($step->problems);
|
||||||
|
}
|
||||||
|
echo '</li>';
|
||||||
|
}
|
||||||
|
echo '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $items */
|
||||||
|
private static function renderList(array $items): void
|
||||||
|
{
|
||||||
|
echo '<ul>';
|
||||||
|
foreach ($items as $item) {
|
||||||
|
echo '<li>' . esc_html($item) . '</li>';
|
||||||
|
}
|
||||||
|
echo '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releasedVersions(Profile $profile): string
|
||||||
|
{
|
||||||
|
return implode(', ', array_map(
|
||||||
|
static fn (array $entry): string => sprintf(
|
||||||
|
/* translators: 1: plugin version, 2: LIT MCP version */
|
||||||
|
__('%1$s (LIT MCP %2$s)', 'wp-lit-mcp'),
|
||||||
|
$entry[$profile->reportVersionKey],
|
||||||
|
$entry['plugin_version']
|
||||||
|
),
|
||||||
|
($this->releases)($profile)->entries()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same coloured icons as the status list on the tab "Verbindung"; the word stays, colour is never alone. */
|
||||||
|
private static function stateBadge(ReleaseState $state): string
|
||||||
|
{
|
||||||
|
[$icon, $word] = match ($state) {
|
||||||
|
ReleaseState::Released => ['yes-alt', __('freigegeben', 'wp-lit-mcp')],
|
||||||
|
ReleaseState::Untested => ['warning', __('ungetestet', 'wp-lit-mcp')],
|
||||||
|
ReleaseState::Incompatible => ['dismiss', __('inkompatibel', 'wp-lit-mcp')],
|
||||||
|
};
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<span class="lit-mcp-state lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s"'
|
||||||
|
. ' aria-hidden="true"></span> <strong>%2$s</strong></span>',
|
||||||
|
$icon,
|
||||||
|
esc_html($word)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function explanation(ReleaseState $state): string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
ReleaseState::Released => __('Alle Fähigkeiten sind nutzbar.', 'wp-lit-mcp'),
|
||||||
|
ReleaseState::Untested => __(
|
||||||
|
'Diese Version ist nicht geprüft. Die ungetesteten Fähigkeiten sind gesperrt, bis sie freigegeben ist.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
),
|
||||||
|
ReleaseState::Incompatible => __(
|
||||||
|
'Die betroffenen Fähigkeiten bleiben gesperrt, bis die Ursache behoben ist; die anderen laufen weiter.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tierHint(Tier $tier): string
|
||||||
|
{
|
||||||
|
return match ($tier) {
|
||||||
|
Tier::ReleasePerVersion => __('Freigabe pro Version nötig', 'wp-lit-mcp'),
|
||||||
|
Tier::LockOnFailure => __(
|
||||||
|
'Freigabe wird dokumentiert, Nutzung automatisch, solange die Prüfung grün ist',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
),
|
||||||
|
Tier::ReportOnly => __('nur Anzeige', 'wp-lit-mcp'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-13
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Admin;
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,12 +32,15 @@ final class SettingsPage
|
|||||||
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
|
* @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 $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
|
||||||
* @param string $version Asset version for cache busting.
|
* @param string $version Asset version for cache busting.
|
||||||
|
* @param (Closure(string): ?string)|null $profileLabel Label of a compatibility profile by ID, null = unknown;
|
||||||
|
* names the profile in the compat_* notices.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private array $baseTabs,
|
private array $baseTabs,
|
||||||
private string $logoUrl = '',
|
private string $logoUrl = '',
|
||||||
private string $assetsUrl = '',
|
private string $assetsUrl = '',
|
||||||
private string $version = ''
|
private string $version = '',
|
||||||
|
private ?Closure $profileLabel = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +79,7 @@ final class SettingsPage
|
|||||||
public function removableQueryArgs(array $args): array
|
public function removableQueryArgs(array $args): array
|
||||||
{
|
{
|
||||||
$args[] = 'lit_mcp_notice';
|
$args[] = 'lit_mcp_notice';
|
||||||
|
$args[] = 'lit_mcp_profile';
|
||||||
|
|
||||||
return $args;
|
return $args;
|
||||||
}
|
}
|
||||||
@@ -107,6 +113,10 @@ final class SettingsPage
|
|||||||
'Tab „Protokoll“: jede Änderung des Assistenten mit Benutzer, Ergebnis und Zeitpunkt.',
|
'Tab „Protokoll“: jede Änderung des Assistenten mit Benutzer, Ergebnis und Zeitpunkt.',
|
||||||
'wp-lit-mcp'
|
'wp-lit-mcp'
|
||||||
),
|
),
|
||||||
|
__('Passt es zu Divi, Pods & Co.?', 'wp-lit-mcp') => __(
|
||||||
|
'Tab „Compatibility“: Fremd-Plugins prüfen, Versionen freigeben und sehen, was gesperrt ist.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
),
|
||||||
];
|
];
|
||||||
$content = '';
|
$content = '';
|
||||||
foreach ($items as $title => $text) {
|
foreach ($items as $title => $text) {
|
||||||
@@ -163,7 +173,7 @@ final class SettingsPage
|
|||||||
public function currentTab(): string
|
public function currentTab(): string
|
||||||
{
|
{
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
|
||||||
$requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
|
$requested = is_string($_GET['tab'] ?? null) ? sanitize_key(wp_unslash($_GET['tab'])) : '';
|
||||||
|
|
||||||
$tabs = $this->tabs();
|
$tabs = $this->tabs();
|
||||||
|
|
||||||
@@ -236,23 +246,26 @@ final class SettingsPage
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "<Label>: " for a compat_* notice naming a known profile; the ID from the address bar only looks the label up. */
|
||||||
|
private function noticeProfile(string $key): string
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
|
||||||
|
$id = is_string($_GET['lit_mcp_profile'] ?? null) ? sanitize_key(wp_unslash($_GET['lit_mcp_profile'])) : '';
|
||||||
|
$label = str_starts_with($key, 'compat_') && $id !== '' && $this->profileLabel !== null
|
||||||
|
? ($this->profileLabel)($id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $label === null ? '' : $label . ': ';
|
||||||
|
}
|
||||||
|
|
||||||
private function renderNotice(): void
|
private function renderNotice(): void
|
||||||
{
|
{
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
|
||||||
$key = isset($_GET['lit_mcp_notice']) ? sanitize_key(wp_unslash((string) $_GET['lit_mcp_notice'])) : '';
|
$key = is_string($_GET['lit_mcp_notice'] ?? null) ? sanitize_key(wp_unslash($_GET['lit_mcp_notice'])) : '';
|
||||||
$notice = match ($key) {
|
$notice = match ($key) {
|
||||||
'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')],
|
'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')],
|
||||||
'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')],
|
'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')],
|
||||||
'cleared' => ['success', __('Protokoll geleert.', '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_check_failed' => [
|
|
||||||
'error',
|
|
||||||
__('Die Divi-Prüfung ist abgebrochen; Details stehen im PHP-Error-Log.', 'wp-lit-mcp'),
|
|
||||||
],
|
|
||||||
'divi_released' => ['success', __('Die Divi-Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')],
|
'divi_released' => ['success', __('Die Divi-Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')],
|
||||||
'divi_release_refused' => [
|
'divi_release_refused' => [
|
||||||
'error',
|
'error',
|
||||||
@@ -270,6 +283,29 @@ final class SettingsPage
|
|||||||
'error',
|
'error',
|
||||||
__('Für die installierte Divi-Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'),
|
__('Für die installierte Divi-Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'),
|
||||||
],
|
],
|
||||||
|
'compat_checked' => ['success', __('Prüfung abgeschlossen; das Ergebnis steht unten.', 'wp-lit-mcp')],
|
||||||
|
'compat_check_running' => [
|
||||||
|
'warning',
|
||||||
|
__('Es läuft bereits eine Prüfung; bitte in ein paar Minuten erneut versuchen.', 'wp-lit-mcp'),
|
||||||
|
],
|
||||||
|
'compat_check_failed' => [
|
||||||
|
'error',
|
||||||
|
__('Die Prüfung ist abgebrochen; Details stehen im PHP-Error-Log.', 'wp-lit-mcp'),
|
||||||
|
],
|
||||||
|
'compat_released' => ['success', __('Die Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')],
|
||||||
|
'compat_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 Version oder zur Plugin-Version. Bitte die Prüfung erneut ausführen.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'compat_withdrawn' => ['success', __('Freigabe zurückgezogen.', 'wp-lit-mcp')],
|
||||||
|
'compat_withdraw_failed' => [
|
||||||
|
'error',
|
||||||
|
__('Für die installierte Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'),
|
||||||
|
],
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,7 +313,7 @@ final class SettingsPage
|
|||||||
printf(
|
printf(
|
||||||
'<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
|
'<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
|
||||||
esc_attr($notice[0]),
|
esc_attr($notice[0]),
|
||||||
esc_html($notice[1])
|
esc_html($this->noticeProfile($key) . $notice[1])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ final class Profile
|
|||||||
* whether the manifest step was clean.
|
* whether the manifest step was clean.
|
||||||
* @param (Closure(): ManifestCheck)|null $manifestCheck The check of this profile; null = its manifests
|
* @param (Closure(): ManifestCheck)|null $manifestCheck The check of this profile; null = its manifests
|
||||||
* without signature checks.
|
* without signature checks.
|
||||||
|
* @param (Closure(CheckStep): string)|null $stepLabel Label of a stored step that has none (reports from
|
||||||
|
* before labels were stored); null = the step's own.
|
||||||
* @param string $reportVersionKey Array key of the version in stored reports and
|
* @param string $reportVersionKey Array key of the version in stored reports and
|
||||||
* release rows (Divi: 'divi_version').
|
* release rows (Divi: 'divi_version').
|
||||||
* @throws LogicException For a ReleasePerVersion profile without matrix or a capability ID given twice.
|
* @throws LogicException For a ReleasePerVersion profile without matrix or a capability ID given twice.
|
||||||
@@ -47,6 +49,7 @@ final class Profile
|
|||||||
public readonly ?Closure $stateKey = null,
|
public readonly ?Closure $stateKey = null,
|
||||||
public readonly ?Closure $extraSteps = null,
|
public readonly ?Closure $extraSteps = null,
|
||||||
public readonly ?Closure $manifestCheck = null,
|
public readonly ?Closure $manifestCheck = null,
|
||||||
|
public readonly ?Closure $stepLabel = null,
|
||||||
public readonly string $reportVersionKey = 'version'
|
public readonly string $reportVersionKey = 'version'
|
||||||
) {
|
) {
|
||||||
if ($tier === Tier::ReleasePerVersion && $matrix === null) {
|
if ($tier === Tier::ReleasePerVersion && $matrix === null) {
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ namespace LIT\MCP\Integrations\Divi\Admin;
|
|||||||
use Closure;
|
use Closure;
|
||||||
use LIT\MCP\Admin\AdminPostAction;
|
use LIT\MCP\Admin\AdminPostAction;
|
||||||
use LIT\MCP\Admin\AdminTab;
|
use LIT\MCP\Admin\AdminTab;
|
||||||
|
use LIT\MCP\Admin\CheckRoute;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\Compatibility\ReleaseState;
|
use LIT\MCP\Compatibility\ReleaseState;
|
||||||
use LIT\MCP\Compatibility\CheckReport;
|
use LIT\MCP\Compatibility\CheckReport;
|
||||||
use LIT\MCP\Compatibility\CheckReportStore;
|
use LIT\MCP\Compatibility\CheckReportStore;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
|
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
|
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
|
||||||
@@ -29,7 +29,7 @@ defined('ABSPATH') || exit;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tab "Divi" (spec §6): release status of the installed Divi, the table of its features (state, source, problems,
|
* Tab "Divi" (spec §6): release status of the installed Divi, the table of its features (state, source, problems,
|
||||||
* affected tools – E3), the latest check report, "Prüfung starten" (REST, DiviCheckRoute), "Divi <Version>
|
* affected tools – E3), the latest check report, "Prüfung starten" (REST, Admin\CheckRoute), "Divi <Version>
|
||||||
* freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (whenever a database release applies,
|
* freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (whenever a database release applies,
|
||||||
* whatever the overall state). Loading the tab removes check pages a dead check left behind – in the page's load
|
* whatever the overall state). Loading the tab removes check pages a dead check left behind – in the page's load
|
||||||
* hook (removeOrphans()), not while rendering. renderNotice() is the admin notice on every admin page while a
|
* hook (removeOrphans()), not while rendering. renderNotice() is the admin notice on every admin page while a
|
||||||
@@ -460,7 +460,7 @@ final class DiviTab implements AdminTab
|
|||||||
&& $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version));
|
&& $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version));
|
||||||
$untested = $status->state === ReleaseState::Untested && $status->version !== '';
|
$untested = $status->state === ReleaseState::Untested && $status->version !== '';
|
||||||
|
|
||||||
printf('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
|
printf('<form method="post" action="%s">', esc_url(CheckRoute::url(DiviProfile::ID)));
|
||||||
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
|
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
|
||||||
// The check is the one thing to do while untested without a usable report; otherwise the release leads.
|
// The check is the one thing to do while untested without a usable report; otherwise the release leads.
|
||||||
submit_button(
|
submit_button(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ defined('ABSPATH') || exit;
|
|||||||
* One confirmed change per lock at a time (final review I-5). Colors and theme options share ONE lock (audit L5):
|
* One confirmed change per lock at a time (final review I-5). Colors and theme options share ONE lock (audit L5):
|
||||||
* both rewrite the whole et_divi array through et_update_option(). The lock is a row lit_mcp_lock_<name> (never
|
* both rewrite the whole et_divi array through et_update_option(). The lock is a row lit_mcp_lock_<name> (never
|
||||||
* autoloaded) in the options table holding "<time>:<token>" – taken with an atomic INSERT IGNORE like
|
* autoloaded) in the options table holding "<time>:<token>" – taken with an atomic INSERT IGNORE like
|
||||||
* DiviCheckRoute (add_option() reads first and writes after, so two requests could both win; audit L6). A lock
|
* Admin\CheckRoute (add_option() reads first and writes after, so two requests could both win; audit L6). A lock
|
||||||
* older than STALE_AFTER seconds (a request that died) is taken over with a conditional UPDATE on the value just
|
* older than STALE_AFTER seconds (a request that died) is taken over with a conditional UPDATE on the value just
|
||||||
* read, so only one taker wins; release() deletes the row only while it still holds this instance's token, so a
|
* read, so only one taker wins; release() deletes the row only while it still holds this instance's token, so a
|
||||||
* holder that lost a stale lock cannot free the new holder's. A taken lock drops the options cache, so the
|
* holder that lost a stale lock cannot free the new holder's. A taken lock drops the options cache, so the
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi;
|
|||||||
use Closure;
|
use Closure;
|
||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
use LIT\MCP\Abilities\ToolAvailability;
|
use LIT\MCP\Abilities\ToolAvailability;
|
||||||
|
use LIT\MCP\Admin\CheckRoute;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\Compatibility\CompatibilityCheck;
|
use LIT\MCP\Compatibility\CompatibilityCheck;
|
||||||
@@ -75,7 +76,6 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
|||||||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
|
||||||
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
|
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||||
@@ -260,12 +260,18 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil
|
|||||||
ReleaseGate::invalidate(mirrors: true);
|
ReleaseGate::invalidate(mirrors: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
|
// The admin check runs as a generic REST route (Admin\CheckRoute, registered by Plugin); a stored check or a
|
||||||
// Tools-tab gate: the check tests Divi, not this site's tool switches.
|
// release changes the state, so the gate forgets its status of this request.
|
||||||
|
add_action(CheckRoute::CHANGED_ACTION, static function (string $profileId): void {
|
||||||
|
if ($profileId === DiviProfile::ID) {
|
||||||
|
ReleaseGate::invalidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spec §7: the same check as WP-CLI command. Its short form has no Tools-tab gate: the check tests Divi,
|
||||||
|
// not this site's tool switches.
|
||||||
$profile = $this->profile();
|
$profile = $this->profile();
|
||||||
$check = static fn (): CompatibilityCheck => CompatibilityCheck::forProfile($profile);
|
DiviCheckCommand::register(static fn (): CompatibilityCheck => CompatibilityCheck::forProfile($profile));
|
||||||
(new DiviCheckRoute($check))->register();
|
|
||||||
DiviCheckCommand::register($check);
|
|
||||||
|
|
||||||
add_filter(
|
add_filter(
|
||||||
SettingsPage::TABS_FILTER,
|
SettingsPage::TABS_FILTER,
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Release;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
|
||||||
use LIT\MCP\Compatibility\CheckReportStore;
|
|
||||||
use LIT\MCP\Compatibility\CompatibilityCheck;
|
|
||||||
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
|
|
||||||
use LIT\MCP\Log;
|
|
||||||
use Throwable;
|
|
||||||
use WP_REST_Response;
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Prüfung starten" of the Divi tab (spec §6.1): POST /wp-json/lit-mcp/v1/divi-check. A REST request on purpose –
|
|
||||||
* not admin-post – so the check runs in the same context as MCP calls (Divi renders its blocks in REST requests,
|
|
||||||
* lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce (cookie authentication); only manage_options.
|
|
||||||
* One check at a time: an atomic "INSERT IGNORE" on the options table (self::LOCK) is the lock – add_option() reads
|
|
||||||
* then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests could both "acquire" it. A
|
|
||||||
* lock older than LOCK_TTL is a dead check's, taken over with a conditional UPDATE (compare-and-swap on the value
|
|
||||||
* just read) so only one of several concurrent takers wins. A check that throws still releases the lock (finally)
|
|
||||||
* and redirects with divi_check_failed (logged, never shown to the browser as an unhandled 500). Answers 303 back
|
|
||||||
* to the tab.
|
|
||||||
*/
|
|
||||||
final class DiviCheckRoute
|
|
||||||
{
|
|
||||||
public const NAMESPACE = 'lit-mcp/v1';
|
|
||||||
public const ROUTE = '/divi-check';
|
|
||||||
public const LOCK = 'lit_mcp_divi_check_lock';
|
|
||||||
|
|
||||||
private const LOCK_TTL = 600;
|
|
||||||
|
|
||||||
/** @param Closure(): CompatibilityCheck $check */
|
|
||||||
public function __construct(private Closure $check, private ?CheckReportStore $reports = null)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
add_action('rest_api_init', [$this, 'registerRoute']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function registerRoute(): void
|
|
||||||
{
|
|
||||||
register_rest_route(self::NAMESPACE, self::ROUTE, [
|
|
||||||
'methods' => '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());
|
|
||||||
$reports = $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
|
|
||||||
$reports->save($report);
|
|
||||||
ReleaseGate::invalidate();
|
|
||||||
$notice = 'divi_checked';
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::failure('divi check', $e);
|
|
||||||
$notice = 'divi_check_failed';
|
|
||||||
} 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
|
|
||||||
{
|
|
||||||
global $wpdb;
|
|
||||||
|
|
||||||
if (self::insertLock($wpdb, time())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache.
|
|
||||||
$since = $wpdb->get_var($wpdb->prepare(
|
|
||||||
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
|
|
||||||
self::LOCK
|
|
||||||
));
|
|
||||||
if ($since === null) {
|
|
||||||
// The lock was released between our failed insert and this read; try once more.
|
|
||||||
return self::insertLock($wpdb, time());
|
|
||||||
}
|
|
||||||
if (time() - (int) $since < self::LOCK_TTL) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$taken = $wpdb->query($wpdb->prepare(
|
|
||||||
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
|
|
||||||
(string) time(),
|
|
||||||
self::LOCK,
|
|
||||||
$since
|
|
||||||
));
|
|
||||||
|
|
||||||
return (int) $taken === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */
|
|
||||||
private static function insertLock(\wpdb $wpdb, int $now): bool
|
|
||||||
{
|
|
||||||
$inserted = $wpdb->query($wpdb->prepare(
|
|
||||||
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
|
|
||||||
self::LOCK,
|
|
||||||
(string) $now
|
|
||||||
));
|
|
||||||
|
|
||||||
return (int) $inserted === 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,6 +46,7 @@ final class DiviProfile
|
|||||||
))->run()
|
))->run()
|
||||||
),
|
),
|
||||||
manifestCheck: DiviManifestCheck::create(...),
|
manifestCheck: DiviManifestCheck::create(...),
|
||||||
|
stepLabel: DiviCheckSteps::label(...),
|
||||||
reportVersionKey: self::REPORT_VERSION_KEY
|
reportVersionKey: self::REPORT_VERSION_KEY
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -14,6 +14,8 @@ use LIT\MCP\Admin\ConnectionRepository;
|
|||||||
use LIT\MCP\Admin\ConnectionStatus;
|
use LIT\MCP\Admin\ConnectionStatus;
|
||||||
use LIT\MCP\Admin\ConnectionTab;
|
use LIT\MCP\Admin\ConnectionTab;
|
||||||
use LIT\MCP\Admin\ConnectionUsage;
|
use LIT\MCP\Admin\ConnectionUsage;
|
||||||
|
use LIT\MCP\Admin\CheckRoute;
|
||||||
|
use LIT\MCP\Admin\CompatibilityTab;
|
||||||
use LIT\MCP\Admin\LimitsTab;
|
use LIT\MCP\Admin\LimitsTab;
|
||||||
use LIT\MCP\Admin\LogTab;
|
use LIT\MCP\Admin\LogTab;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
@@ -94,6 +96,8 @@ final class Plugin
|
|||||||
// Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
|
// Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
|
||||||
// page reads its tabs.
|
// page reads its tabs.
|
||||||
$registry->boot();
|
$registry->boot();
|
||||||
|
// The admin check of every compatibility profile (POST lit-mcp/v1/<id>-check), REST like the MCP calls.
|
||||||
|
(new CheckRoute($registry->compatibility()))->register();
|
||||||
|
|
||||||
(new AbilityRegistrar($registry, $policy, self::guards($registry, $changeLog)))->register();
|
(new AbilityRegistrar($registry, $policy, self::guards($registry, $changeLog)))->register();
|
||||||
(new ServerRegistrar(
|
(new ServerRegistrar(
|
||||||
@@ -113,12 +117,14 @@ final class Plugin
|
|||||||
new ToolsTab($registry, $policy),
|
new ToolsTab($registry, $policy),
|
||||||
new LogTab($changeLog, $limits, $registry),
|
new LogTab($changeLog, $limits, $registry),
|
||||||
new LimitsTab(),
|
new LimitsTab(),
|
||||||
|
self::compatibilityTab($registry, $changeLog),
|
||||||
];
|
];
|
||||||
self::$settingsPage = new SettingsPage(
|
self::$settingsPage = new SettingsPage(
|
||||||
$tabs,
|
$tabs,
|
||||||
plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile),
|
plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile),
|
||||||
plugins_url('assets', self::$pluginFile),
|
plugins_url('assets', self::$pluginFile),
|
||||||
self::VERSION
|
self::VERSION,
|
||||||
|
static fn (string $id): ?string => $registry->compatibility()->profile($id)?->label
|
||||||
);
|
);
|
||||||
(new ConnectionUsage())->register();
|
(new ConnectionUsage())->register();
|
||||||
if (is_admin()) {
|
if (is_admin()) {
|
||||||
@@ -129,6 +135,16 @@ final class Plugin
|
|||||||
\WPMedia\MCP\OAuth\Bootstrap::instance();
|
\WPMedia\MCP\OAuth\Bootstrap::instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab "Compatibility" over the registry's profiles: check, release and withdraw for all of them.
|
||||||
|
*
|
||||||
|
* @internal Public for PluginTest only.
|
||||||
|
*/
|
||||||
|
public static function compatibilityTab(IntegrationRegistry $registry, ChangeLog $changeLog): CompatibilityTab
|
||||||
|
{
|
||||||
|
return new CompatibilityTab($registry->compatibility(), $changeLog);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guards of every tool call. DiviFeatureGate judges the Divi IDs; CapabilityGate the IDs of all other profiles
|
* The guards of every tool call. DiviFeatureGate judges the Divi IDs; CapabilityGate the IDs of all other profiles
|
||||||
* and locks the unknown ones – without it a tool with an unknown ID would run unlocked.
|
* and locks the unknown ones – without it a tool with an unknown ID would run unlocked.
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ defined('ABSPATH') || exit;
|
|||||||
* names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION,
|
* names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION,
|
||||||
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\
|
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\
|
||||||
* lit_mcp_<profile>_releases (Compatibility\ReleaseStore), lit_mcp_<profile>_check_report
|
* lit_mcp_<profile>_releases (Compatibility\ReleaseStore), lit_mcp_<profile>_check_report
|
||||||
* (Compatibility\CheckReportStore) and lit_mcp_<profile>_check_lock (Divi\Release\DiviCheckRoute::LOCK) for the
|
* (Compatibility\CheckReportStore) and lit_mcp_<profile>_check_lock (Admin\CheckRoute) for the
|
||||||
* profiles divi and pods (an explicit list: a LIKE pattern could hit foreign options),
|
* profiles divi and pods (an explicit list: a LIKE pattern could hit foreign options),
|
||||||
* Integrations\Divi\Design\DesignLock::option(), Admin\ConnectionStatus::ENDPOINT_TRANSIENT,
|
* Integrations\Divi\Design\DesignLock::option(), Admin\ConnectionStatus::ENDPOINT_TRANSIENT,
|
||||||
* Integrations\Divi\Release\ReleaseResolver::CACHE,
|
* Integrations\Divi\Release\ReleaseResolver::CACHE,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <Plugin>', $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 <b>Label</b>', $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 <Plugin></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 <Plugin>"', $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 <Plugin>"', $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 <i>x</i> 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('<b>x</b>', $html);
|
||||||
|
self::assertStringContainsString('<script>alert(1)</script>', $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ final class SettingsPageTest extends TestCase
|
|||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
unset($_GET['tab'], $_GET['lit_mcp_notice']);
|
unset($_GET['tab'], $_GET['lit_mcp_notice'], $_GET['lit_mcp_profile']);
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +195,6 @@ final class SettingsPageTest extends TestCase
|
|||||||
public static function diviNotices(): array
|
public static function diviNotices(): array
|
||||||
{
|
{
|
||||||
return [
|
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_released', 'success', 'für LIT MCP freigegeben'],
|
||||||
['divi_release_refused', 'error', 'Prüfung erneut ausführen'],
|
['divi_release_refused', 'error', 'Prüfung erneut ausführen'],
|
||||||
['divi_withdrawn', 'success', 'Freigabe zurückgezogen'],
|
['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 <1>: ', $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')]);
|
$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
|
public function testAddsAnOverviewHelpTab(): void
|
||||||
@@ -221,6 +303,7 @@ final class SettingsPageTest extends TestCase
|
|||||||
static fn (array $tab): bool => $tab['id'] === 'lit-mcp-overview'
|
static fn (array $tab): bool => $tab['id'] === 'lit-mcp-overview'
|
||||||
&& str_contains($tab['content'], 'Tab „Verbindung“')
|
&& str_contains($tab['content'], 'Tab „Verbindung“')
|
||||||
&& str_contains($tab['content'], 'Tab „Tools“')
|
&& str_contains($tab['content'], 'Tab „Tools“')
|
||||||
|
&& str_contains($tab['content'], 'Tab „Compatibility“')
|
||||||
));
|
));
|
||||||
|
|
||||||
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->helpTabFor($screen);
|
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->helpTabFor($screen);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||||
|
|
||||||
use LIT\MCP\Compatibility\CompatibilityMatrix;
|
use LIT\MCP\Compatibility\CompatibilityMatrix;
|
||||||
|
use LIT\MCP\Compatibility\CheckStep;
|
||||||
use LIT\MCP\Compatibility\ManifestCheck;
|
use LIT\MCP\Compatibility\ManifestCheck;
|
||||||
use LIT\MCP\Compatibility\Profile;
|
use LIT\MCP\Compatibility\Profile;
|
||||||
use LIT\MCP\Compatibility\Tier;
|
use LIT\MCP\Compatibility\Tier;
|
||||||
@@ -57,10 +58,12 @@ final class ProfileTest extends TestCase
|
|||||||
self::assertNull($bare->stateKey);
|
self::assertNull($bare->stateKey);
|
||||||
self::assertNull($bare->extraSteps);
|
self::assertNull($bare->extraSteps);
|
||||||
self::assertNull($bare->manifestCheck);
|
self::assertNull($bare->manifestCheck);
|
||||||
|
self::assertNull($bare->stepLabel);
|
||||||
|
|
||||||
$stateKey = static fn (string $version): string => 'k' . $version;
|
$stateKey = static fn (string $version): string => 'k' . $version;
|
||||||
$extraSteps = static fn (bool $manifestClean): array => [];
|
$extraSteps = static fn (bool $manifestClean): array => [];
|
||||||
$manifestCheck = static fn (): ManifestCheck => new ManifestCheck([], signatures: false);
|
$manifestCheck = static fn (): ManifestCheck => new ManifestCheck([], signatures: false);
|
||||||
|
$stepLabel = static fn (CheckStep $step): string => 'L' . $step->key;
|
||||||
$full = new Profile(
|
$full = new Profile(
|
||||||
id: 'fake',
|
id: 'fake',
|
||||||
label: 'Fake',
|
label: 'Fake',
|
||||||
@@ -69,12 +72,14 @@ final class ProfileTest extends TestCase
|
|||||||
testedVersions: ['1.0'],
|
testedVersions: ['1.0'],
|
||||||
stateKey: $stateKey,
|
stateKey: $stateKey,
|
||||||
extraSteps: $extraSteps,
|
extraSteps: $extraSteps,
|
||||||
manifestCheck: $manifestCheck
|
manifestCheck: $manifestCheck,
|
||||||
|
stepLabel: $stepLabel
|
||||||
);
|
);
|
||||||
self::assertSame(['1.0'], $full->testedVersions);
|
self::assertSame(['1.0'], $full->testedVersions);
|
||||||
self::assertSame($stateKey, $full->stateKey);
|
self::assertSame($stateKey, $full->stateKey);
|
||||||
self::assertSame($extraSteps, $full->extraSteps);
|
self::assertSame($extraSteps, $full->extraSteps);
|
||||||
self::assertSame($manifestCheck, $full->manifestCheck);
|
self::assertSame($manifestCheck, $full->manifestCheck);
|
||||||
|
self::assertSame($stepLabel, $full->stepLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAReleasePerVersionProfileWithoutMatrixIsAProgrammingError(): void
|
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\DiviIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\DiviLogoUsage;
|
use LIT\MCP\Integrations\Divi\DiviLogoUsage;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
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\DiviFeature;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||||||
@@ -325,9 +324,12 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
$upgraded = $callback;
|
$upgraded = $callback;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
Actions\expectAdded('rest_api_init')->once()->with(Mockery::on(
|
$changed = null;
|
||||||
static fn (array $callback): bool => $callback[0] instanceof DiviCheckRoute
|
Actions\expectAdded('lit_mcp_compat_changed')->once()->whenHappen(
|
||||||
));
|
static function (callable $callback) use (&$changed): void {
|
||||||
|
$changed = $callback;
|
||||||
|
}
|
||||||
|
);
|
||||||
Filters\expectAdded(SettingsPage::TABS_FILTER)->once()->whenHappen(
|
Filters\expectAdded(SettingsPage::TABS_FILTER)->once()->whenHappen(
|
||||||
static function (callable $callback) use (&$tabs): void {
|
static function (callable $callback) use (&$tabs): void {
|
||||||
$tabs = $callback;
|
$tabs = $callback;
|
||||||
@@ -339,6 +341,10 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
(new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->boot();
|
(new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->boot();
|
||||||
self::assertIsCallable($upgraded);
|
self::assertIsCallable($upgraded);
|
||||||
$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);
|
self::assertIsCallable($tabs);
|
||||||
$all = $tabs([$base]);
|
$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;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Compatibility\CheckStep;
|
||||||
use LIT\MCP\Compatibility\ManifestCheck;
|
use LIT\MCP\Compatibility\ManifestCheck;
|
||||||
use LIT\MCP\Compatibility\ReleaseState;
|
use LIT\MCP\Compatibility\ReleaseState;
|
||||||
use LIT\MCP\Compatibility\Tier;
|
use LIT\MCP\Compatibility\Tier;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
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\DiviFeature;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviReleases;
|
use LIT\MCP\Integrations\Divi\Release\DiviReleases;
|
||||||
@@ -71,5 +73,9 @@ final class DiviProfileTest extends TestCase
|
|||||||
self::assertSame(DiviReleases::TESTED, $profile->testedVersions);
|
self::assertSame(DiviReleases::TESTED, $profile->testedVersions);
|
||||||
self::assertSame(ReleaseResolver::stateKey('5.3.0'), ($profile->stateKey)('5.3.0'));
|
self::assertSame(ReleaseResolver::stateKey('5.3.0'), ($profile->stateKey)('5.3.0'));
|
||||||
self::assertInstanceOf(ManifestCheck::class, ($profile->manifestCheck)());
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Brain\Monkey\Filters;
|
|||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Abilities\ExposurePolicy;
|
use LIT\MCP\Abilities\ExposurePolicy;
|
||||||
use LIT\MCP\Abilities\ToolAvailability;
|
use LIT\MCP\Abilities\ToolAvailability;
|
||||||
|
use LIT\MCP\Admin\CompatibilityTab;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
use LIT\MCP\Compatibility\CapabilityGate;
|
use LIT\MCP\Compatibility\CapabilityGate;
|
||||||
use LIT\MCP\Compatibility\Compatibility;
|
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
|
public function testTheBootableIntegrationsBootExactlyOnce(): void
|
||||||
{
|
{
|
||||||
Actions\expectAdded('upgrader_process_complete')->once();
|
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();
|
Filters\expectAdded(SettingsPage::TABS_FILTER)->once();
|
||||||
$registry = self::registry();
|
$registry = self::registry();
|
||||||
|
|
||||||
@@ -137,6 +138,26 @@ final class PluginTest extends TestCase
|
|||||||
self::assertInstanceOf(CapabilityGate::class, $guards[1]);
|
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
|
private static function readOnly(object $source): bool
|
||||||
{
|
{
|
||||||
return (new ReflectionProperty($source, 'readOnly'))->getValue($source);
|
return (new ReflectionProperty($source, 'readOnly'))->getValue($source);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use LIT\MCP\Access;
|
|||||||
use LIT\MCP\Admin\ConnectionStatus;
|
use LIT\MCP\Admin\ConnectionStatus;
|
||||||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||||||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
|
||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||||||
use LIT\MCP\Limits;
|
use LIT\MCP\Limits;
|
||||||
use LIT\MCP\Updater\RecheckOnInstall;
|
use LIT\MCP\Updater\RecheckOnInstall;
|
||||||
@@ -54,7 +53,7 @@ final class UninstallTest extends TestCase
|
|||||||
|
|
||||||
$own = [
|
$own = [
|
||||||
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
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_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_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
|
||||||
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
|
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use LIT\MCP\Admin\ConnectionStatus;
|
|||||||
use LIT\MCP\Admin\LogTab;
|
use LIT\MCP\Admin\LogTab;
|
||||||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||||||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
|
||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||||||
use LIT\MCP\Limits;
|
use LIT\MCP\Limits;
|
||||||
use LIT\MCP\Uninstaller;
|
use LIT\MCP\Uninstaller;
|
||||||
@@ -60,7 +59,7 @@ final class UninstallerTest extends TestCase
|
|||||||
self::assertSame(
|
self::assertSame(
|
||||||
[
|
[
|
||||||
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
|
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_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_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
|
||||||
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
|
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
|
||||||
|
|||||||
Reference in New Issue
Block a user