Files
WP-LIT-MCP/tests/Unit/Admin/CompatibilityTabTest.php
T
Jan-Niclas LoosenandClaude Opus 5.5 05bf890481 feat(cache): WP Super Cache über den Kompatibilitätskern anbinden
SuperCacheFeature, SuperCacheCompatibility (LockOnFailure, getestet 3.1.5, Version aus dem Plugin-Header) und SuperCacheIntegration ohne Tools. Der SuperCacheListener sammelt Änderungen über lit_mcp_changed und leert einmal am Request-Ende: einen Beitrag per wp_cache_post_edit, mehrere Beiträge oder seitenweite Änderungen per wp_cache_clear_cache (Multisite: eigener Blog). Komplett-Purges höchstens alle 30 s, mit genau einem Nachlauf per WP-Cron. Freigabe-Einträge leeren nichts. Uninstaller räumt Optionen, Transient und Cron-Hook ab; Reiter und lit-site-status zeigen das Profil automatisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:50:35 +01:00

907 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
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();
}
/** One source for the ability name of the release and withdraw entries (SuperCacheListener reads it too). */
public function testTheReleaseAbilityNameHasOneSource(): void
{
self::assertSame('lit/divi-release', CompatibilityTab::releaseAbility('divi'));
self::assertTrue(CompatibilityTab::isReleaseAbility(CompatibilityTab::releaseAbility('wpsc')));
foreach (['lit/content-update', 'lit/-release', 'lit/release', 'lit/divi-releases'] as $other) {
self::assertFalse(CompatibilityTab::isReleaseAbility($other), $other);
}
}
public function testItIsTheTabCompatibility(): void
{
$tab = $this->tab(self::profile());
self::assertInstanceOf(AdminTab::class, $tab);
self::assertSame('compatibility', $tab->slug());
self::assertSame('Compatibility', $tab->label());
}
public function testAnInactiveProfileIsOneLineAndNeverAsksForTheMatrix(): void
{
Actions\expectDone('lit_mcp_compat_profile_extra')->never();
// ReleasePerVersion with a matrix closure that fails the test when called.
$html = self::html($this->tab(self::profile(Tier::ReleasePerVersion, null, active: false)));
self::assertStringContainsString('nicht installiert oder nicht aktiv', $html);
self::assertStringContainsString('Fake &lt;Plugin&gt;', $html);
self::assertStringNotContainsString('<form', $html);
self::assertStringNotContainsString('<table', $html);
}
/** @dataProvider states */
public function testTheOverallStateIsShownAsWordAndIcon(ReleaseState $state, string $word, string $icon): void
{
$html = self::html($this->tab(self::profile(Tier::ReleasePerVersion, self::matrix($state, ['Problem A']))));
self::assertStringContainsString('<strong>' . $word . '</strong>', $html);
self::assertStringContainsString('dashicons-' . $icon, $html);
self::assertStringContainsString('aria-hidden="true"', $html);
}
/** @return array<string, array{ReleaseState, string, string}> */
public static function states(): array
{
return [
'released' => [ReleaseState::Released, 'freigegeben', 'yes-alt'],
'untested' => [ReleaseState::Untested, 'ungetestet', 'warning'],
'incompatible' => [ReleaseState::Incompatible, 'inkompatibel', 'dismiss'],
];
}
public function testItShowsTheVersionAndTheProblemsAndTheCapabilityTable(): void
{
$html = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Incompatible, ['Problem A']))
));
self::assertStringContainsString('1.2.0', $html);
self::assertStringContainsString('Problem A', $html);
self::assertStringContainsString('Fake fake-read', $html);
self::assertStringContainsString('Fake fake-write', $html);
}
/** @dataProvider tierTexts */
public function testTheTierHintSaysWhatTheReleaseMeans(Tier $tier, string $text): void
{
$matrix = $tier === Tier::ReleasePerVersion ? self::matrix(ReleaseState::Released) : null;
$html = self::html($this->tab(self::profile($tier, $matrix)));
self::assertStringContainsString($text, $html);
}
/** @return array<string, array{Tier, string}> */
public static function tierTexts(): array
{
return [
'release' => [Tier::ReleasePerVersion, 'Freigabe pro Version nötig'],
'lock' => [
Tier::LockOnFailure,
'Freigabe wird dokumentiert, Nutzung automatisch, solange die Prüfung grün ist',
],
'report' => [Tier::ReportOnly, 'nur Anzeige'],
];
}
public function testLockOnFailureIsLockedWhenTheProfileIsActiveButTooOld(): void
{
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
version: static fn (): string => '0.9',
minVersion: '1.0'
);
$tab = new CompatibilityTab(new Compatibility([$profile]), FakeChangeLog::forbidden());
Functions\when('get_option')->justReturn(false);
$html = self::html($tab);
self::assertStringContainsString('<strong>inkompatibel</strong>', $html);
self::assertStringContainsString('Mindestversion', $html);
}
public function testTestedAndReleasedVersionsAreListed(): void
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->allows('entries')->andReturn([
['version' => '1.1.0', 'plugin_version' => '0.7.0', 'user_id' => 1, 'released_at' => 'x', 'report_id' => 'r'],
]);
$releases->allows('find')->andReturn(null);
$html = self::html($this->tab(self::profile(tested: ['1.0.0', '1.0.1']), null, $releases));
self::assertStringContainsString('1.0.0, 1.0.1', $html);
self::assertStringContainsString('1.1.0', $html);
self::assertStringContainsString('LIT MCP 0.7.0', $html);
}
public function testNoVersionListsShowADashWhenEmpty(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('Getestete Versionen', $html);
self::assertStringContainsString('Freigegebene Versionen', $html);
self::assertSame(2, substr_count($html, '<td>–</td>'));
}
public function testTheLatestReportListsEveryStepWithIconAndLabel(): void
{
$profile = self::profile(stepLabel: static fn (CheckStep $s): string => $s->label ?? 'Alt:' . $s->key);
$html = self::html($this->tab($profile, self::report()));
self::assertStringContainsString('Letzte Prüfung', $html);
self::assertStringContainsString('Manifest &lt;b&gt;Label&lt;/b&gt;', $html);
self::assertStringContainsString('Alt:old', $html);
self::assertStringContainsString('<strong>bestanden</strong>', $html);
}
public function testAFailedStepSaysSoInVisibleText(): void
{
$html = self::html($this->tab(self::profile(), self::report(false)));
self::assertStringContainsString('<strong>fehlgeschlagen</strong>', $html);
}
public function testTheCapabilityTableHasACaptionNamingTheProfile(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString(
'<caption class="screen-reader-text">Fähigkeiten von Fake &lt;Plugin&gt;</caption>',
$html
);
}
public function testAProfileCanAddColumnsToTheCapabilityTableAndTheTabEscapesThem(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->with([], 'fake')->andReturn([
[
'heading' => 'Quelle <b>',
'cell' => static fn (FakeCapability $c, CompatibilityMatrix $m): array => $c === FakeCapability::Read
? ['<i>a</i>']
: [],
],
[
'heading' => 'Tools',
'cell' => static fn (FakeCapability $c, CompatibilityMatrix $m): array => ['tool-' . $c->id()],
'code' => true,
],
]);
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('<th scope="col">Quelle &lt;b&gt;</th>', $html);
self::assertStringContainsString('<td data-label="Quelle &lt;b&gt;"><ul><li>&lt;i&gt;a&lt;/i&gt;</li></ul></td>', $html);
self::assertStringContainsString('<td data-label="Quelle &lt;b&gt;">–</td>', $html);
self::assertStringContainsString('<li><code>tool-' . FakeCapability::Read->id() . '</code></li>', $html);
self::assertStringNotContainsString('<i>a</i>', $html);
}
public function testTheCapabilityTableIsOpenUnlessReleasedAndFolded(): void
{
$released = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released))
));
$untested = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested))
));
self::assertStringContainsString('<details class="lit-mcp-compat-details">', $released);
self::assertStringContainsString('<details class="lit-mcp-compat-details" open>', $untested);
self::assertStringContainsString('lit-mcp-compat-features', $released);
}
public function testEveryButtonNamesItsProfileForAssistiveTechnology(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$withdrawable = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()))
. self::html($this->tab($withdrawable, self::report(), self::store(self::entry())));
self::assertStringContainsString('aria-label="Prüfung starten: Fake &lt;Plugin&gt;"', $html);
// The release label names the profile itself.
self::assertStringContainsString('>Fake <Plugin> 1.2.0 freigeben</button>', $html);
self::assertStringContainsString('aria-label="Freigabe zurückziehen: Fake &lt;Plugin&gt;"', $html);
}
public function testEachFormButtonHasAUniqueNameInsteadOfSubmit(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$withdrawable = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()))
. self::html($this->tab($withdrawable, self::report(), self::store(self::entry())));
self::assertStringNotContainsString('name="submit"', $html);
self::assertStringContainsString('name="lit-mcp-compat-release-fake"', $html);
self::assertStringContainsString('name="lit-mcp-compat-withdraw-fake"', $html);
self::assertStringContainsString('name="lit-mcp-compat-check-fake"', $html);
}
public function testTheLoadHookIsRegisteredOnTheSettingsPage(): void
{
$tab = $this->tab(self::profile());
Actions\expectAdded('load-settings_page_lit-mcp')->once()->with([$tab, 'loadProfiles']);
$tab->registerActions();
}
public function testTheLoadActionFiresPerProfileOnTheOwnTabOnly(): void
{
$_GET['tab'] = 'compatibility';
Actions\expectDone('lit_mcp_compat_profile_load')->once()->with('fake');
$this->tab(self::profile())->loadProfiles();
}
public function testTheLoadActionStaysQuietOnOtherTabsAndForOthersThanAdministrators(): void
{
Actions\expectDone('lit_mcp_compat_profile_load')->never();
foreach (['verbindung', '', ['compatibility']] as $tabParam) {
$_GET['tab'] = $tabParam;
$this->tab(self::profile())->loadProfiles();
}
unset($_GET['tab']);
$this->tab(self::profile())->loadProfiles();
$_GET['tab'] = 'compatibility';
Functions\when('current_user_can')->justReturn(false);
$this->tab(self::profile())->loadProfiles();
}
public function testRenderingDoesNotFireTheLoadAction(): void
{
$_GET['tab'] = 'compatibility';
Actions\expectDone('lit_mcp_compat_profile_load')->never();
self::html($this->tab(self::profile()));
}
public function testAnArrayAsProfileFieldIsRefusedWithoutAWarning(): void
{
$this->actionsDie();
$_POST['profile'] = ['fake'];
$this->expectException(\RuntimeException::class);
$this->tab(self::profile())->release();
}
public function testASkippedStepIsMarkedAsNotRun(): void
{
$report = new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '1.2.0', Plugin::VERSION, 'k', [
new CheckStep('write', [], true, 'Schreibtest'),
]);
$html = self::html($this->tab(self::profile(), $report));
self::assertStringContainsString('Schreibtest', $html);
self::assertStringContainsString('<strong>übersprungen</strong>', $html);
self::assertStringNotContainsString('screen-reader-text">übersprungen', $html);
self::assertStringContainsString('nicht ausgeführt', $html);
}
public function testAStepWithoutLabelAndWithoutLabelSourceShowsItsKey(): void
{
$html = self::html($this->tab(self::profile(), self::report()));
self::assertStringContainsString('</strong>: old', $html);
}
public function testAFailedStepListsItsProblems(): void
{
$html = self::html($this->tab(self::profile(), self::report(false)));
self::assertStringContainsString('fehlgeschlagen', $html);
self::assertStringContainsString('Funktion &lt;i&gt;x&lt;/i&gt; fehlt', $html);
}
public function testWithoutAReportNothingIsListed(): void
{
self::assertStringNotContainsString('Letzte Prüfung', self::html($this->tab(self::profile())));
}
public function testTheProfileExtraHookRunsLastWithTheProfileId(): void
{
Actions\expectDone('lit_mcp_compat_profile_extra')->once()->with('fake');
self::html($this->tab(self::profile(), self::report()));
}
public function testTheProfileIntroRunsBeforeTheCapabilityTableAndTheExtraAfterIt(): void
{
Actions\expectDone(CompatibilityTab::INTRO_ACTION)->once()->with('fake')->whenHappen(static function (): void {
echo '[INTRO]';
});
Actions\expectDone(CompatibilityTab::EXTRA_ACTION)->once()->with('fake')->whenHappen(static function (): void {
echo '[EXTRA]';
});
$html = self::html($this->tab(self::profile(), self::report()));
self::assertLessThan(strpos($html, 'lit-mcp-compat-details'), strpos($html, '[INTRO]'));
self::assertGreaterThan(strpos($html, '</table></details>'), strpos($html, '[EXTRA]'));
}
public function testAnInactiveProfileFiresNeitherIntroNorExtra(): void
{
Actions\expectDone(CompatibilityTab::INTRO_ACTION)->never();
Actions\expectDone(CompatibilityTab::EXTRA_ACTION)->never();
self::html($this->tab(self::profile(active: false)));
}
public function testAProfileCanReplaceTheGenericExplanationAndTheTabEscapesIt(): void
{
Filters\expectApplied(CompatibilityTab::EXPLANATION_FILTER)
->once()
->with('Alle Fähigkeiten sind nutzbar.', 'fake', ReleaseState::Released)
->andReturn('Eigener <b>Text</b>');
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('Eigener &lt;b&gt;Text&lt;/b&gt;', $html);
self::assertStringNotContainsString('Alle Fähigkeiten sind nutzbar.', $html);
}
public function testANonStringExplanationFromTheFilterKeepsTheGenericText(): void
{
Filters\expectApplied(CompatibilityTab::EXPLANATION_FILTER)->once()->andReturn(['x']);
self::assertStringContainsString('Alle Fähigkeiten sind nutzbar.', self::html($this->tab(self::profile())));
}
/** @return array{heading: string, cell: Closure} */
private static function column(string $heading, Closure $cell): array
{
return ['heading' => $heading, 'cell' => $cell];
}
public function testInvalidColumnsAreDroppedAndTheRestIsReindexed(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->andReturn([
3 => self::column('Gut', static fn (): array => ['ok']),
5 => ['heading' => 42, 'cell' => static fn (): array => ['x']],
6 => ['heading' => 'Ohne Closure', 'cell' => 'strlen'],
7 => 'kaputt',
9 => ['heading' => 'Ohne Zelle'],
]);
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('<th scope="col">Gut</th>', $html);
self::assertSame(3, substr_count($html, '<th scope="col">'));
self::assertStringContainsString('<td data-label="Gut"><ul><li>ok</li></ul></td>', $html);
}
public function testAThrowingCellShowsADashLogsTheFailureAndKeepsTheTableClosed(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->andReturn([
self::column('Kaputt', static function (): array {
throw new \RuntimeException('Zelle <kaputt>');
}),
self::column('Heil', static fn (): array => ['ok']),
]);
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$html = self::html($this->tab(self::profile()));
} finally {
ini_set('error_log', (string) $previous);
}
$logged = (string) file_get_contents($log);
unlink($log);
self::assertStringContainsString('<td data-label="Kaputt">–</td>', $html);
self::assertStringContainsString('<td data-label="Heil"><ul><li>ok</li></ul></td>', $html);
self::assertStringContainsString('</tbody></table></details>', $html);
self::assertStringContainsString('fake capability column "Kaputt" failed', $logged);
self::assertStringContainsString('Zelle <kaputt>', $logged);
}
public function testACellThatReturnsNoListShowsADash(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->andReturn([
self::column('Text', static fn (): string => 'kein Array'),
self::column('Karte', static fn (): array => ['a' => 'x']),
self::column('Zahlen', static fn (): array => [1, 2]),
]);
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('<td data-label="Text">–</td>', $html);
self::assertStringContainsString('<td data-label="Karte">–</td>', $html);
self::assertStringContainsString('<td data-label="Zahlen">–</td>', $html);
}
public function testANonArrayColumnsFilterResultMeansNoExtraColumns(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->andReturn('kaputt');
self::assertSame(2, substr_count(self::html($this->tab(self::profile())), '<th scope="col">'));
}
public function testEverythingFromThirdPartiesIsEscaped(): void
{
$profile = self::profile(
Tier::ReleasePerVersion,
self::matrix(ReleaseState::Incompatible, ['<script>alert(1)</script>']),
version: '<b>x</b>',
tested: ['<i>t</i>']
);
$html = self::html($this->tab($profile, self::report(false, '<b>x</b>')));
self::assertStringNotContainsString('<script>', $html);
self::assertStringNotContainsString('<b>x</b>', $html);
self::assertStringNotContainsString('<i>t</i>', $html);
self::assertStringNotContainsString('<i>x</i>', $html);
self::assertStringNotContainsString('<b>Label</b>', $html);
self::assertStringContainsString('&lt;b&gt;x&lt;/b&gt;', $html);
self::assertStringContainsString('&lt;script&gt;alert(1)&lt;/script&gt;', $html);
}
// --- 1.21: forms and actions -------------------------------------------------------------------------------
private function actionsDie(): void
{
Functions\when('wp_die')->alias(static function (): never {
throw new \RuntimeException('died');
});
}
private static function entry(string $version = '1.2.0'): array
{
return [
'version' => $version,
'plugin_version' => Plugin::VERSION,
'user_id' => 1,
'released_at' => '2026-10-01T08:00:00+00:00',
'report_id' => 'r1',
];
}
/** A store with the given entries for the installed state; add()/withdraw() expectations are the test's. */
private static function store(?array $entry = null): ReleaseStore
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->allows('entries')->andReturn($entry === null ? [] : [$entry]);
$releases->allows('find')->andReturn($entry);
return $releases;
}
public function testRegistersTheAdminPostHandlers(): void
{
$tab = $this->tab(self::profile());
Actions\expectAdded('admin_post_lit_mcp_compat_release')->once()->with([$tab, 'release']);
Actions\expectAdded('admin_post_lit_mcp_compat_withdraw')->once()->with([$tab, 'withdraw']);
$tab->registerActions();
}
public function testTheCheckFormPostsToTheRestRouteWithTheRestNonce(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('action="https://k.de/wp-json/lit-mcp/v1/fake-check"', $html);
self::assertStringContainsString('name="_wpnonce" value="nonce-wp_rest"', $html);
self::assertStringContainsString('Prüfung starten', $html);
}
public function testReleaseNeedsAFreshGreenReportForTheInstalledState(): void
{
$_POST['profile'] = 'fake';
$reports = [null, self::report(false), self::report(true, '1.1.0', 'key1.1.0'), self::report(true, '1.2.0', 'old')];
foreach ($reports as $report) {
$this->redirect = null;
$releases = self::store();
$releases->expects('add')->never();
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab(self::profile(), $report, $releases)->release();
self::assertStringContainsString('lit_mcp_notice=compat_release_refused', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_profile=fake', (string) $this->redirect);
}
}
public function testReleaseStoresTheEntryLogsItAndAnnouncesTheChange(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('add')->once()->with('1.2.0', Plugin::VERSION, 1, 'r1');
$log = new FakeChangeLog();
Actions\expectDone('lit_mcp_compat_changed')->once()->with('fake');
$this->tab(self::profile(), self::report(), $releases, $log)->release();
self::assertSame(['lit_mcp_compat_release_fake'], $this->nonces);
self::assertStringContainsString('tab=compatibility', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_notice=compat_released', (string) $this->redirect);
self::assertSame('lit/fake-release', $log->records[0]['ability']);
self::assertSame('fake', $log->records[0]['object_type']);
self::assertSame('1.2.0', $log->records[0]['object_id']);
self::assertStringContainsString('Fake <Plugin> 1.2.0', $log->records[0]['summary']);
}
/** @dataProvider refusedReleases */
public function testReleaseIsRefusedWhereTheTabOffersNone(Profile $profile, ?array $entry): void
{
$_POST['profile'] = 'fake';
$releases = self::store($entry);
$releases->expects('add')->never();
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab($profile, self::report(), $releases)->release();
self::assertStringContainsString('lit_mcp_notice=compat_release_refused', (string) $this->redirect);
}
/** @return array<string, array{Profile, ?array}> */
public static function refusedReleases(): array
{
return [
'version tested in the plugin' => [self::profile(tested: ['1.2.0']), null],
'already released' => [self::profile(), self::entry()],
'report only' => [self::profile(Tier::ReportOnly), null],
'inactive' => [self::profile(active: false), null],
'version unknown' => [self::profile(version: null), null],
'per version, but already released' => [
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released)),
null,
],
'per version, but incompatible' => [
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Incompatible, ['x'])),
null,
],
];
}
public function testAReleasePerVersionProfileIsReleasedWhileUntested(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('add')->once();
Actions\expectDone('lit_mcp_compat_changed')->once();
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$this->tab($profile, self::report(), $releases, new FakeChangeLog())->release();
self::assertStringContainsString('lit_mcp_notice=compat_released', (string) $this->redirect);
}
public function testAnUnknownProfileIsRefusedBeforeTheNonceOrAnyStore(): void
{
$this->actionsDie();
Actions\expectDone('lit_mcp_compat_changed')->never();
$releases = self::store();
$releases->expects('add')->never();
$releases->expects('withdraw')->never();
foreach (['nope', '', "fake' OR 1=1"] as $id) {
$_POST['profile'] = $id;
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected the request to stop');
} catch (\RuntimeException $e) {
self::assertSame('died', $e->getMessage());
}
}
}
self::assertNull($this->redirect);
self::assertSame([], $this->nonces);
}
public function testAMissingProfileFieldIsRefused(): void
{
$this->actionsDie();
unset($_POST['profile']);
$this->expectException(\RuntimeException::class);
$this->tab(self::profile())->release();
}
public function testActionsNeedManageOptionsAndTheNonceOfTheProfile(): void
{
$_POST['profile'] = 'fake';
$this->actionsDie();
Functions\when('current_user_can')->justReturn(false);
$releases = self::store();
$releases->expects('add')->never();
$releases->expects('withdraw')->never();
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected 403');
} catch (\RuntimeException $e) {
self::assertSame('died', $e->getMessage());
}
}
// With the right but a failing nonce check (wrong nonce: WordPress dies) nothing is stored either.
Functions\when('current_user_can')->justReturn(true);
Functions\when('check_admin_referer')->alias(static function (): never {
throw new \RuntimeException('bad nonce');
});
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected the nonce to stop');
} catch (\RuntimeException $e) {
self::assertSame('bad nonce', $e->getMessage());
}
}
}
public function testWithdrawRemovesTheEntryOfTheInstalledVersionLogsItAndAnnouncesTheChange(): void
{
$_POST['profile'] = 'fake';
$releases = self::store(self::entry());
$releases->expects('withdraw')->once()->with('1.2.0', Plugin::VERSION)->andReturn(true);
$log = new FakeChangeLog();
Actions\expectDone('lit_mcp_compat_changed')->once()->with('fake');
$this->tab(self::profile(), null, $releases, $log)->withdraw();
self::assertSame(['lit_mcp_compat_withdraw_fake'], $this->nonces);
self::assertStringContainsString('lit_mcp_notice=compat_withdrawn', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_profile=fake', (string) $this->redirect);
self::assertSame('lit/fake-release', $log->records[0]['ability']);
}
public function testWithdrawWithoutAnEntryFails(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('withdraw')->once()->andReturn(false);
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab(self::profile(), null, $releases)->withdraw();
self::assertStringContainsString('lit_mcp_notice=compat_withdraw_failed', (string) $this->redirect);
}
public function testUntestedWithAFreshGreenReportOffersTheRelease(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$html = self::html($this->tab($profile, self::report()));
self::assertStringContainsString('name="action" value="lit_mcp_compat_release"', $html);
self::assertStringContainsString('name="profile" value="fake"', $html);
self::assertStringContainsString('value="nonce-lit_mcp_compat_release_fake"', $html);
self::assertStringContainsString('1.2.0 freigeben', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testUntestedWithoutAFreshReportAsksForACheckInsteadOfOfferingTheRelease(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
foreach ([null, self::report(false)] as $report) {
$html = self::html($this->tab($profile, $report));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringContainsString('Freigeben geht erst nach einer vollständig grünen Prüfung', $html);
}
}
public function testAReleasedPerVersionProfileOffersNeitherReleaseNorWithdrawal(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testLockOnFailureOffersTheReleaseForAVersionNotTestedInThePlugin(): void
{
$html = self::html($this->tab(self::profile(), self::report()));
self::assertStringContainsString('value="lit_mcp_compat_release"', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testATestedVersionAndReportOnlyOfferNoRelease(): void
{
foreach ([self::profile(tested: ['1.2.0']), self::profile(Tier::ReportOnly)] as $profile) {
$html = self::html($this->tab($profile, self::report()));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringNotContainsString('Freigeben geht erst', $html);
}
}
public function testAnExistingReleaseCanBeWithdrawnWhateverTheState(): void
{
foreach ([ReleaseState::Released, ReleaseState::Incompatible] as $state) {
$profile = self::profile(Tier::ReleasePerVersion, self::matrix($state, $state === ReleaseState::Released ? [] : ['x']));
$html = self::html($this->tab($profile, self::report(), self::store(self::entry())));
self::assertStringContainsString('value="lit_mcp_compat_withdraw"', $html);
self::assertStringContainsString('value="nonce-lit_mcp_compat_withdraw_fake"', $html);
self::assertStringNotContainsString('value="lit_mcp_compat_release"', $html);
}
}
}