- Art column: only lit/media-delete and lit/divi-delete say "löscht", lit/post-trash "Papierkorb", other destructive tools "überschreibt". - ConnectionUsage records only responses with status < 400 (mcp-oauth answers auth failures with WP_REST_Response 401, not WP_Error). - Log result column: translated "unbekannt" instead of the raw value. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
375 lines
19 KiB
PHP
375 lines
19 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Admin;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\ExposurePolicy;
|
||
use LIT\MCP\Admin\ToolsTab;
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ToolsTabTest extends TestCase
|
||
{
|
||
/** @param list<array{0: string, 1: bool, 2: bool, 3?: bool}> $abilities name, isDirect, readonly, destructive */
|
||
private function integration(string $id, array $abilities, bool $available = true): Integration
|
||
{
|
||
$mocks = [];
|
||
foreach ($abilities as $spec) {
|
||
[$name, $direct, $readonly] = $spec;
|
||
$ability = Mockery::mock(Ability::class);
|
||
$ability->allows('name')->andReturn($name);
|
||
$ability->allows('label')->andReturn('Label ' . $name);
|
||
$ability->allows('isDirect')->andReturn($direct);
|
||
$ability->allows('annotations')->andReturn(['readonly' => $readonly, 'destructive' => $spec[3] ?? false]);
|
||
$mocks[] = $ability;
|
||
}
|
||
$integration = Mockery::mock(Integration::class);
|
||
$integration->allows('id')->andReturn($id);
|
||
$integration->allows('isAvailable')->andReturn($available);
|
||
$integration->allows('abilities')->andReturn($mocks);
|
||
|
||
return $integration;
|
||
}
|
||
|
||
private function registry(): IntegrationRegistry
|
||
{
|
||
return new IntegrationRegistry(
|
||
$this->integration('core', [['lit/guide', true, true], ['lit/site-status', true, true]]),
|
||
$this->integration('media', [['lit/media-preview', true, true], ['lit/media-upload', false, false]]),
|
||
$this->integration('divi', [['lit/divi-outline', true, true], ['lit/divi-update', true, false]]),
|
||
$this->integration('pods', [['lit/pods-x', true, true]], false)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, string|bool> $option
|
||
* @param mixed $closedBoxes the user's saved closed postboxes (false = never saved)
|
||
*/
|
||
private function render(array $option, ?IntegrationRegistry $registry = null, mixed $closedBoxes = false): string
|
||
{
|
||
Functions\stubEscapeFunctions();
|
||
Functions\stubTranslationFunctions();
|
||
Functions\when('settings_fields')->justReturn(null);
|
||
Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit'): void {
|
||
echo '<button name="' . $name . '">Speichern</button>';
|
||
});
|
||
Functions\when('wp_nonce_field')->alias(static function (string $action, string $name): string {
|
||
return '<input type="hidden" id="' . $name . '" name="' . $name . '" value="nonce-' . $action . '">';
|
||
});
|
||
Functions\when('get_user_option')->alias(
|
||
static fn (string $key) => $key === 'closedpostboxes_settings_page_lit-mcp' ? $closedBoxes : false
|
||
);
|
||
Functions\when('selected')->alias(
|
||
static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : ''
|
||
);
|
||
Functions\when('checked')->alias(
|
||
static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' checked="checked"' : ''
|
||
);
|
||
|
||
ob_start();
|
||
(new ToolsTab($registry ?? $this->registry(), new ExposurePolicy($option)))->render();
|
||
|
||
return (string) ob_get_clean();
|
||
}
|
||
|
||
private function select(string $html, string $key): string
|
||
{
|
||
$start = strpos($html, '<select name="lit_mcp_exposure[' . $key . ']"');
|
||
self::assertNotFalse($start, 'No select for ' . $key);
|
||
|
||
return substr($html, $start, (int) strpos($html, '</select>', $start) - $start);
|
||
}
|
||
|
||
/** The table row of one tool (from its <tr> to </tr>). */
|
||
private function row(string $html, string $name): string
|
||
{
|
||
$at = strpos($html, '<span class="lit-mcp-tool-name">' . $name . '</span>');
|
||
self::assertNotFalse($at, 'No row for ' . $name);
|
||
$start = (int) strrpos(substr($html, 0, $at), '<tr');
|
||
|
||
return substr($html, $start, (int) strpos($html, '</tr>', $at) - $start);
|
||
}
|
||
|
||
/** The postbox of one group (from its opening div to the next group or the end). */
|
||
private function group(string $html, string $label): string
|
||
{
|
||
$at = strpos($html, '<h2 class="hndle">' . $label . ' ');
|
||
self::assertNotFalse($at, 'No group ' . $label);
|
||
$start = (int) strrpos(substr($html, 0, $at), '<div id="lit-mcp-group-');
|
||
$end = strpos($html, '<div id="lit-mcp-group-', $at);
|
||
|
||
return substr($html, $start, ($end === false ? strlen($html) : $end) - $start);
|
||
}
|
||
|
||
public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void
|
||
{
|
||
$tab = new ToolsTab($this->registry(), new ExposurePolicy());
|
||
$callback = null;
|
||
Functions\expect('register_setting')->once()->with(
|
||
'lit_mcp_tools',
|
||
'lit_mcp_exposure',
|
||
Mockery::on(static function (array $args) use (&$callback): bool {
|
||
$callback = $args['sanitize_callback'];
|
||
return true;
|
||
})
|
||
);
|
||
|
||
$tab->registerSettings();
|
||
|
||
self::assertSame([$tab, 'sanitize'], $callback);
|
||
self::assertSame(
|
||
['@group:pods' => 'disabled', '@readonly:divi' => true, 'lit/menu-list' => 'disabled'],
|
||
$tab->sanitize([
|
||
'@readonly:divi' => '1',
|
||
'@group:pods' => 'disabled',
|
||
'@group:shop' => 'direct',
|
||
'lit/menu-list' => 'disabled',
|
||
'lit/guide' => 'disabled',
|
||
])
|
||
);
|
||
}
|
||
|
||
public function testGroupsToolsByAvailableIntegrationWithOnlyAllowedModes(): void
|
||
{
|
||
$html = $this->render(['lit/divi-update' => 'disabled']);
|
||
|
||
self::assertSame(3, substr_count($html, 'class="postbox lit-mcp-group'));
|
||
self::assertStringContainsString('<h2 class="hndle">Grundlagen <span class="count">(2 Tools)</span>', $html);
|
||
self::assertStringContainsString('<h2 class="hndle">Medien <span class="count">(2 Tools)</span>', $html);
|
||
self::assertStringContainsString('<h2 class="hndle">Divi <span class="count">(2 Tools)</span>', $html);
|
||
self::assertStringNotContainsString('pods', $html);
|
||
self::assertStringContainsString('lit/divi-outline', $this->group($html, 'Divi'));
|
||
self::assertStringNotContainsString('lit/guide', $this->group($html, 'Divi'));
|
||
|
||
$groupSelect = $this->select($html, '@group:divi');
|
||
self::assertStringContainsString('<option value="" selected="selected">Standard der Tools</option>', $groupSelect);
|
||
foreach (['direct', 'indirect', 'disabled'] as $mode) {
|
||
self::assertStringContainsString('value="' . $mode . '"', $groupSelect);
|
||
}
|
||
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1"', $html);
|
||
self::assertStringContainsString('<label for="lit-mcp-group-mode-divi">Ganze Gruppe</label>', $html);
|
||
self::assertStringContainsString('id="lit-mcp-group-mode-divi"', $groupSelect);
|
||
self::assertStringContainsString('Nur lesen', $this->group($html, 'Divi'));
|
||
self::assertStringContainsString('<option value="disabled">aus</option>', $groupSelect);
|
||
|
||
$guide = $this->select($html, 'lit/guide');
|
||
self::assertStringNotContainsString('value="disabled"', $guide);
|
||
self::assertStringNotContainsString('value="indirect"', $guide);
|
||
self::assertStringContainsString('value="" selected="selected">Standard (direkt)', $guide);
|
||
self::assertStringNotContainsString('value="indirect"', $this->select($html, 'lit/media-preview'));
|
||
self::assertStringContainsString('value="disabled" selected="selected"', $this->select($html, 'lit/divi-update'));
|
||
}
|
||
|
||
public function testToolsOfAGroupWithOverrideOfferWieGruppeAndTheGroupSaysGemischt(): void
|
||
{
|
||
$html = $this->render(['@group:divi' => 'indirect', 'lit/divi-update' => 'direct']);
|
||
|
||
self::assertStringContainsString('value="indirect" selected="selected"', $this->select($html, '@group:divi'));
|
||
self::assertStringContainsString(
|
||
'<option value="" selected="selected">wie Gruppe (indirekt)</option>',
|
||
$this->select($html, 'lit/divi-outline')
|
||
);
|
||
$update = $this->select($html, 'lit/divi-update');
|
||
self::assertStringContainsString('<option value="">wie Gruppe (indirekt)</option>', $update);
|
||
self::assertStringContainsString('value="direct" selected="selected"', $update);
|
||
self::assertStringContainsString('gemischt', $this->group($html, 'Divi'));
|
||
self::assertStringNotContainsString('gemischt', $this->group($html, 'Medien'));
|
||
self::assertStringContainsString('Standard (direkt)', $this->select($html, 'lit/site-status'));
|
||
}
|
||
|
||
public function testDirectOnlyToolsExplainThatIndirectDoesNotApply(): void
|
||
{
|
||
$html = $this->render(['@group:media' => 'indirect']);
|
||
|
||
self::assertStringContainsString('wie Gruppe (direkt)', $this->select($html, 'lit/media-preview'));
|
||
self::assertStringContainsString('„indirekt“ geht hier nicht', $this->row($html, 'lit/media-preview'));
|
||
self::assertStringNotContainsString('geht hier nicht', $this->row($html, 'lit/media-upload'));
|
||
}
|
||
|
||
public function testReadonlyDisablesWriteToolSelectsButKeepsTheirOverride(): void
|
||
{
|
||
$html = $this->render(['@readonly:divi' => true, 'lit/divi-update' => 'indirect']);
|
||
|
||
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html);
|
||
$update = $this->row($html, 'lit/divi-update');
|
||
self::assertStringContainsString('<select name="lit_mcp_exposure[lit/divi-update]" disabled', $update);
|
||
self::assertStringContainsString('Durch „Nur lesen“ aus', $update);
|
||
self::assertStringContainsString(
|
||
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
|
||
$update
|
||
);
|
||
$outline = $this->row($html, 'lit/divi-outline');
|
||
self::assertStringNotContainsString('disabled>', $outline);
|
||
self::assertStringNotContainsString('Nur lesen', $outline);
|
||
}
|
||
|
||
public function testCountsDirectToolsUnderTheSavedSettings(): void
|
||
{
|
||
$html = $this->render(['@group:divi' => 'disabled']);
|
||
|
||
// guide, site-status, media-preview (divi disabled, media-upload indirect by default).
|
||
self::assertStringContainsString('Direkt sichtbar: 3 Tools', $html);
|
||
self::assertStringNotContainsString('Kontext', $html);
|
||
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'Direkt sichtbar'));
|
||
self::assertStringContainsString('<div class="notice notice-info inline"><p><strong>Direkt sichtbar: 3 Tools', $html);
|
||
}
|
||
|
||
public function testWarnsWhenMoreThan25ToolsAreDirect(): void
|
||
{
|
||
$many = [];
|
||
for ($i = 1; $i <= 26; $i++) {
|
||
$many[] = ['lit/tool-' . $i, true, true];
|
||
}
|
||
$registry = new IntegrationRegistry($this->integration('wordpress', $many));
|
||
|
||
$warned = $this->render([], $registry);
|
||
$notWarned = $this->render(['lit/tool-1' => 'indirect'], $registry);
|
||
|
||
self::assertStringContainsString('Direkt sichtbar: 26 Tools', $warned);
|
||
self::assertStringContainsString('Kontext', $warned);
|
||
self::assertStringContainsString('<div class="notice notice-warning inline">', $warned);
|
||
self::assertStringNotContainsString('notice-warning', $notWarned);
|
||
self::assertStringContainsString('<h2 class="hndle">WordPress <span class="count">(26 Tools)</span>', $warned);
|
||
self::assertStringContainsString('Direkt sichtbar: 25 Tools', $notWarned);
|
||
self::assertStringNotContainsString('Kontext', $notWarned);
|
||
}
|
||
|
||
public function testUnknownGroupsUseTheirIdAndEverythingIsEscaped(): void
|
||
{
|
||
$registry = new IntegrationRegistry($this->integration('shop', [['lit/shop-<b>', true, true]]));
|
||
|
||
$html = $this->render([], $registry);
|
||
|
||
self::assertStringContainsString('<h2 class="hndle">shop <span class="count">(1 Tool)</span>', $html);
|
||
self::assertStringNotContainsString('<b>', $html);
|
||
self::assertStringContainsString('lit/shop-<b>', $html);
|
||
}
|
||
|
||
public function testArtColumnSaysWhetherAToolReadsWritesOrDeletes(): void
|
||
{
|
||
$registry = new IntegrationRegistry($this->integration('media', [
|
||
['lit/media-preview', true, true],
|
||
['lit/media-upload', false, false],
|
||
]));
|
||
|
||
$html = $this->render([], $registry);
|
||
|
||
self::assertStringContainsString('<th scope="col" class="column-kind">Art</th>', $html);
|
||
self::assertStringContainsString('dashicons-visibility', $this->row($html, 'lit/media-preview'));
|
||
self::assertStringContainsString('>liest<', $this->row($html, 'lit/media-preview'));
|
||
self::assertStringContainsString('dashicons-edit', $this->row($html, 'lit/media-upload'));
|
||
self::assertStringContainsString('>schreibt<', $this->row($html, 'lit/media-upload'));
|
||
}
|
||
|
||
/**
|
||
* "destructive" is a broad hint (deleting, replacing, swapping) – only explicit deletes say "löscht".
|
||
*
|
||
* @dataProvider destructiveAbilities
|
||
*/
|
||
public function testArtColumnForEveryDestructiveAbility(string $name, string $icon, string $word): void
|
||
{
|
||
$registry = new IntegrationRegistry($this->integration('wordpress', [[$name, false, false, true]]));
|
||
|
||
$row = $this->row($this->render([], $registry), $name);
|
||
|
||
self::assertStringContainsString('dashicons-' . $icon, $row);
|
||
self::assertStringContainsString('>' . $word . '<', $row);
|
||
}
|
||
|
||
/** @return list<list<string>> */
|
||
public static function destructiveAbilities(): array
|
||
{
|
||
return [
|
||
['lit/media-delete', 'trash', 'löscht'],
|
||
['lit/divi-delete', 'trash', 'löscht'],
|
||
['lit/post-trash', 'trash', 'Papierkorb'],
|
||
['lit/post-replace-with-copy', 'update', 'überschreibt'],
|
||
['lit/revision-restore', 'update', 'überschreibt'],
|
||
['lit/menu-set-items', 'update', 'überschreibt'],
|
||
['lit/menu-assign-location', 'update', 'überschreibt'],
|
||
['lit/divi-replace-text', 'update', 'überschreibt'],
|
||
];
|
||
}
|
||
|
||
public function testToolCellShowsLabelAndTheNameAsMutedTextWithALabelledSelect(): void
|
||
{
|
||
$html = $this->render([]);
|
||
$row = $this->row($html, 'lit/guide');
|
||
|
||
self::assertStringContainsString('<strong>Label lit/guide</strong>', $row);
|
||
self::assertStringNotContainsString('<code>', $row);
|
||
self::assertMatchesRegularExpression('#<label class="screen-reader-text" for="([a-z0-9-]+)">[^<]*Label lit/guide</label>#', $row);
|
||
preg_match('#for="([a-z0-9-]+)"#', $row, $m);
|
||
self::assertStringContainsString('id="' . $m[1] . '"', $this->select($html, 'lit/guide'));
|
||
}
|
||
|
||
public function testIntroUsesPlainLanguageAndMentionsDiviOnlyWhenDiviIsAvailable(): void
|
||
{
|
||
$withDivi = $this->render([]);
|
||
$withoutDivi = $this->render([], new IntegrationRegistry($this->integration('core', [['lit/guide', true, true]])));
|
||
|
||
self::assertStringContainsString('Claude sieht das Tool sofort.', $withDivi);
|
||
self::assertStringContainsString('Claude findet es bei Bedarf über die Tool-Suche.', $withDivi);
|
||
self::assertStringNotContainsString('Meta-Tools', $withDivi);
|
||
self::assertStringNotContainsString('discover', $withDivi);
|
||
self::assertStringContainsString('Divi-Code-Modul', $withDivi);
|
||
self::assertStringNotContainsString('Divi-Code-Modul', $withoutDivi);
|
||
}
|
||
|
||
public function testHasASubmitButtonAboveAndBelowTheGroups(): void
|
||
{
|
||
$html = $this->render([]);
|
||
|
||
self::assertSame(2, substr_count($html, 'Speichern</button>'));
|
||
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'name="submit-top"'));
|
||
self::assertGreaterThan(strrpos($html, 'class="postbox'), strrpos($html, 'name="submit"'));
|
||
}
|
||
|
||
public function testGroupsAreWordPressPostboxesWithAnAccessibleToggle(): void
|
||
{
|
||
$html = $this->render([]);
|
||
$divi = $this->group($html, 'Divi');
|
||
|
||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $divi);
|
||
self::assertStringContainsString('<div class="postbox-header">', $divi);
|
||
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="true">', $divi);
|
||
self::assertStringContainsString('<span class="screen-reader-text">Gruppe ein-/ausblenden: Divi</span>', $divi);
|
||
self::assertStringContainsString('<span class="toggle-indicator" aria-hidden="true"></span>', $divi);
|
||
self::assertStringContainsString('<div class="inside">', $divi);
|
||
// The group controls live in the header but outside the clickable heading.
|
||
$heading = substr($divi, (int) strpos($divi, '<h2'), (int) strpos($divi, '</h2>') - (int) strpos($divi, '<h2'));
|
||
self::assertStringNotContainsString('<select', $heading);
|
||
self::assertStringNotContainsString('<input', $heading);
|
||
self::assertLessThan(strpos($divi, 'class="inside"'), strpos($divi, 'lit-mcp-group-mode-divi'));
|
||
// Nonce for WordPress' own "closed-postboxes" state saving.
|
||
self::assertStringContainsString('id="closedpostboxesnonce" name="closedpostboxesnonce" value="nonce-closedpostboxes"', $html);
|
||
}
|
||
|
||
public function testHeaderSummaryCountsEffectiveModes(): void
|
||
{
|
||
$html = $this->render(['@readonly:divi' => true]);
|
||
|
||
self::assertStringContainsString('<span class="lit-mcp-group-summary">2 direkt · 0 indirekt · 0 aus</span>', $this->group($html, 'Grundlagen'));
|
||
self::assertStringContainsString('<span class="lit-mcp-group-summary">1 direkt · 1 indirekt · 0 aus</span>', $this->group($html, 'Medien'));
|
||
self::assertStringContainsString('<span class="lit-mcp-group-summary">nur lesen · 1 direkt · 0 indirekt · 1 aus</span>', $this->group($html, 'Divi'));
|
||
}
|
||
|
||
public function testAGroupWithAllToolsOffStartsClosedUnlessTheUserSavedOtherwise(): void
|
||
{
|
||
$default = $this->render(['@group:divi' => 'disabled']);
|
||
$saved = $this->render(['@group:divi' => 'disabled'], null, ['lit-mcp-group-media']);
|
||
|
||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group closed">', $default);
|
||
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="false">', $this->group($default, 'Divi'));
|
||
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group">', $default);
|
||
|
||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $saved);
|
||
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed">', $saved);
|
||
}
|
||
}
|