feat: Protokoll tab as a core-style list table
The change summary is the wide primary column (key "title", so it cannot
be hidden) with the tool's human label below and row actions "Revision
ansehen" / "Objekt öffnen". Objects get human names ("Seite: Kontakt",
"Menü: …", "Einstellungen: Titel der Website"; deleted/trashed marked),
primed per page with one query each for posts, menus and users. Times are
relative within 24 h. Non-ok results show a badge and a marked row instead
of an Ergebnis column. Page size and columns via Screen Options; the tool
filter shows labels grouped by integration, dates have visible labels,
and an active filter offers "Filter zurücksetzen" (also in the empty state).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
+127
-36
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Tests\Unit\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Admin\LogTab;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Limits;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
@@ -16,13 +19,45 @@ final class LogTabTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($_GET['ability'], $_GET['user_id'], $_GET['result'], $_GET['date_from'], $_GET['date_to']);
|
||||
unset($_GET['ability'], $_GET['user_id'], $_GET['result'], $_GET['date_from'], $_GET['date_to'], $_GET['tab']);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function tab(ChangeLog $log): LogTab
|
||||
{
|
||||
return new LogTab($log, new Limits(), $this->registry());
|
||||
}
|
||||
|
||||
private function registry(): IntegrationRegistry
|
||||
{
|
||||
return new IntegrationRegistry(
|
||||
$this->integration('media', 'Medien', ['lit/media-upload-from-url' => 'Datei von URL importieren']),
|
||||
$this->integration('wordpress', 'WordPress', ['lit/content-update' => 'Inhalt <ändern>'])
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $abilities name => label */
|
||||
private function integration(string $id, string $label, array $abilities): Integration
|
||||
{
|
||||
$mocks = [];
|
||||
foreach ($abilities as $name => $abilityLabel) {
|
||||
$ability = Mockery::mock(Ability::class);
|
||||
$ability->allows('name')->andReturn($name);
|
||||
$ability->allows('label')->andReturn($abilityLabel);
|
||||
$mocks[] = $ability;
|
||||
}
|
||||
$integration = Mockery::mock(Integration::class);
|
||||
$integration->allows('id')->andReturn($id);
|
||||
$integration->allows('label')->andReturn($label);
|
||||
$integration->allows('isAvailable')->andReturn(true);
|
||||
$integration->allows('abilities')->andReturn($mocks);
|
||||
|
||||
return $integration;
|
||||
}
|
||||
|
||||
public function testFiltersAreReadSanitized(): void
|
||||
{
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
$tab = $this->tab(Mockery::mock(ChangeLog::class));
|
||||
$_GET['ability'] = 'lit/content-update';
|
||||
$_GET['user_id'] = '3x';
|
||||
|
||||
@@ -34,7 +69,7 @@ final class LogTabTest extends TestCase
|
||||
|
||||
public function testExtraFiltersWhitelistResultAndDateRange(): void
|
||||
{
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
$tab = $this->tab(Mockery::mock(ChangeLog::class));
|
||||
|
||||
self::assertSame(
|
||||
['result' => '', 'date_from' => '', 'date_to' => ''],
|
||||
@@ -54,7 +89,7 @@ final class LogTabTest extends TestCase
|
||||
|
||||
public function testExtraFiltersRejectUnknownResultAndMalformedDates(): void
|
||||
{
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
$tab = $this->tab(Mockery::mock(ChangeLog::class));
|
||||
|
||||
$_GET['result'] = 'ok"><script>';
|
||||
$_GET['date_from'] = 'not-a-date';
|
||||
@@ -70,7 +105,7 @@ final class LogTabTest extends TestCase
|
||||
public function testFiltersRejectATrailingNewlineEvenWithoutTheSanitizer(): void
|
||||
{
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
$tab = $this->tab(Mockery::mock(ChangeLog::class));
|
||||
$_GET['ability'] = "lit/content-update\n";
|
||||
$_GET['date_from'] = "2026-09-01\n";
|
||||
$_GET['date_to'] = "2026-09-25\n";
|
||||
@@ -93,7 +128,7 @@ final class LogTabTest extends TestCase
|
||||
$log->expects('clear')->once();
|
||||
$terminated = false;
|
||||
|
||||
(new LogTab($log, new Limits(), static function () use (&$terminated): void {
|
||||
(new LogTab($log, new Limits(), $this->registry(), static function () use (&$terminated): void {
|
||||
$terminated = true;
|
||||
}))->handleClear();
|
||||
|
||||
@@ -110,24 +145,100 @@ final class LogTabTest extends TestCase
|
||||
$log->expects('clear')->never();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
(new LogTab($log, new Limits()))->handleClear();
|
||||
$this->tab($log)->handleClear();
|
||||
}
|
||||
|
||||
public function testRegistersTheClearAction(): void
|
||||
public function testRegistersTheClearActionAndTheScreenOptions(): void
|
||||
{
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
$tab = $this->tab(Mockery::mock(ChangeLog::class));
|
||||
Functions\expect('add_action')->once()->with('admin_post_lit_mcp_clear_log', [$tab, 'handleClear']);
|
||||
Functions\expect('add_action')->once()->with('load-settings_page_lit-mcp', [$tab, 'loadScreen']);
|
||||
Functions\expect('add_filter')->once()->with('set_screen_option_lit_mcp_log_per_page', [$tab, 'savePerPage'], 10, 3);
|
||||
|
||||
$tab->registerActions();
|
||||
}
|
||||
|
||||
public function testLoadScreenAddsThePerPageScreenOptionOnlyOnThisTab(): void
|
||||
{
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\when('sanitize_key')->returnArg();
|
||||
$tab = $this->tab(Mockery::mock(ChangeLog::class));
|
||||
|
||||
Functions\expect('add_screen_option')->never();
|
||||
$_GET['tab'] = 'tools';
|
||||
$tab->loadScreen();
|
||||
|
||||
Functions\expect('add_screen_option')->once()->with('per_page', [
|
||||
'label' => 'Einträge pro Seite',
|
||||
'default' => 20,
|
||||
'option' => 'lit_mcp_log_per_page',
|
||||
]);
|
||||
$_GET['tab'] = 'protokoll';
|
||||
$tab->loadScreen();
|
||||
}
|
||||
|
||||
public function testSavePerPageKeepsOnlyItsOwnOption(): void
|
||||
{
|
||||
$tab = $this->tab(Mockery::mock(ChangeLog::class));
|
||||
|
||||
self::assertSame(40, $tab->savePerPage(false, 'lit_mcp_log_per_page', '40'));
|
||||
self::assertFalse($tab->savePerPage(false, 'other_per_page', '40'));
|
||||
}
|
||||
|
||||
public function testRenderShowsFiltersAndAConfirmProtectedClearButton(): void
|
||||
{
|
||||
$html = $this->render(['lit/content-update', 'lit/media-upload-from-url', 'lit/x"><script>']);
|
||||
|
||||
self::assertStringContainsString('name="result"', $html);
|
||||
self::assertStringNotContainsString('<script>', $html);
|
||||
self::assertStringContainsString('lit_mcp_clear_log', $html);
|
||||
self::assertStringContainsString('confirm(', $html);
|
||||
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
|
||||
|
||||
// One intro line; the retention days link to the Limits tab.
|
||||
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
|
||||
// Selects keep screen-reader labels; the dates have short visible ones.
|
||||
foreach (['ability', 'user-id', 'result'] as $field) {
|
||||
self::assertStringContainsString('<label class="screen-reader-text" for="lit-mcp-filter-' . $field . '">', $html);
|
||||
}
|
||||
self::assertStringContainsString('<label for="lit-mcp-filter-date-from">von</label>', $html);
|
||||
self::assertStringContainsString('<label for="lit-mcp-filter-date-to">bis</label>', $html);
|
||||
self::assertStringContainsString('<option value="ok">erfolgreich</option>', $html);
|
||||
self::assertStringContainsString('<option value="partial">teilweise</option>', $html);
|
||||
self::assertStringNotContainsString('Filter zurücksetzen', $html, 'no reset without an active filter');
|
||||
}
|
||||
|
||||
public function testToolFilterShowsHumanLabelsGroupedByIntegration(): void
|
||||
{
|
||||
$_GET['ability'] = 'lit/content-update';
|
||||
|
||||
$html = $this->render(['lit/content-update', 'lit/gone', 'lit/media-upload-from-url']);
|
||||
|
||||
self::assertStringContainsString(
|
||||
'<optgroup label="Medien"><option value="lit/media-upload-from-url">Datei von URL importieren</option></optgroup>'
|
||||
. '<optgroup label="WordPress"><option value="lit/content-update" selected="selected">Inhalt <ändern></option></optgroup>'
|
||||
. '<optgroup label="Weitere"><option value="lit/gone">lit/gone</option></optgroup>',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnActiveFilterOffersTheReset(): void
|
||||
{
|
||||
$_GET['date_from'] = '2026-09-01';
|
||||
|
||||
$html = $this->render([]);
|
||||
|
||||
self::assertStringContainsString(
|
||||
'<a class="lit-mcp-reset" href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=protokoll">Filter zurücksetzen</a>',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<string> $abilities abilities found in the log */
|
||||
private function render(array $abilities): string
|
||||
{
|
||||
Functions\stubEscapeFunctions();
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\when('selected')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' selected="selected"' : '');
|
||||
Functions\when('checked')->justReturn('');
|
||||
Functions\when('settings_fields')->justReturn(null);
|
||||
Functions\when('submit_button')->alias(static function (
|
||||
string $text = '',
|
||||
string $type = 'primary',
|
||||
@@ -150,36 +261,16 @@ final class LogTabTest extends TestCase
|
||||
});
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||
Functions\when('get_option')->justReturn([]);
|
||||
Functions\when('get_user_option')->justReturn(false);
|
||||
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
|
||||
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->allows('abilities')->andReturn(['lit/content-update', 'lit/x"><script>']);
|
||||
$log->allows('abilities')->andReturn($abilities);
|
||||
$log->allows('query')->andReturn(['rows' => [], 'total' => 0]);
|
||||
|
||||
ob_start();
|
||||
(new LogTab($log, new Limits()))->render();
|
||||
$html = (string) ob_get_clean();
|
||||
$this->tab($log)->render();
|
||||
|
||||
self::assertStringContainsString('name="result"', $html);
|
||||
self::assertStringContainsString('name="date_from"', $html);
|
||||
self::assertStringContainsString('name="date_to"', $html);
|
||||
self::assertStringContainsString('lit/content-update', $html);
|
||||
self::assertStringNotContainsString('<script>', $html);
|
||||
self::assertStringContainsString('lit_mcp_clear_log', $html);
|
||||
self::assertStringContainsString('confirm(', $html);
|
||||
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
|
||||
|
||||
// One intro line; the retention days link to the Limits tab.
|
||||
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
|
||||
// Every filter control has a (screen-reader) label.
|
||||
foreach (['ability', 'user_id', 'result', 'date_from', 'date_to'] as $name) {
|
||||
self::assertMatchesRegularExpression(
|
||||
'#<label class="screen-reader-text" for="lit-mcp-filter-' . str_replace('_', '-', $name) . '">#',
|
||||
$html,
|
||||
'label for ' . $name
|
||||
);
|
||||
}
|
||||
self::assertStringContainsString('<option value="ok">erfolgreich</option>', $html);
|
||||
self::assertStringContainsString('<option value="partial">teilweise</option>', $html);
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user