Files
Jan-Niclas LoosenandClaude Fable 5.1 1b73f0652a fix: Admin-UX aus dem Review – Live-Gruppenfeedback, Protokollfilter, Divi-Hinweis, Rollen-Rückfrage
- Tools: „Ganze Gruppe“ und „Nur lesen“ wirken sofort auf die Zeilen
  (Option „wie Gruppe (…)“, gesperrte Selects mit Hinweis), der Server
  bleibt die Wahrheit; Markup trägt die nötigen data-Attribute.
- Protokoll: Filter in eigenem Block über der Tabelle (Core blendet
  .tablenav .actions auf Telefonen aus); „Protokoll leeren“ nur bei
  Einträgen, rechtsbündig, mit Gesamtzahl über ChangeLog::count().
- Divi: Beschreibung unter „Prüfung starten“, Button primär solange
  ungetestet ohne frischen Bericht.
- Zugriff: Rückfrage vor dem Speichern ohne Rolle oder ohne eigene Rolle.
- Verbindung: HTTPS-Hinweis nicht doppelt neben der Statusliste.
- Mobil: breitere Einstellungs-Spalte im Tools-Reiter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:39:44 +02:00

278 lines
12 KiB
PHP
Raw Permalink 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\Functions;
use LIT\MCP\Admin\LogListTable;
use LIT\MCP\Admin\LogObjects;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class LogListTableTest extends TestCase
{
private const NOW = 1790441353; // 2026-09-26 16:49:13 UTC
protected function setUp(): void
{
parent::setUp();
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
// Harmless defaults so a full row renders in tests that only care about one column.
Functions\when('get_userdata')->justReturn(false);
Functions\when('post_type_exists')->justReturn(false);
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->justReturn(false);
Functions\when('get_user_option')->justReturn(false);
Functions\when('get_option')->alias(static fn (string $key) => $key === 'date_format' ? 'j. F Y' : 'H:i');
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
}
protected function tearDown(): void
{
unset($_GET['paged']);
parent::tearDown();
}
/**
* @param array<string, mixed> $filters
* @param array<string, string> $toolLabels
*/
private function table(array $filters = [], array $toolLabels = [], ?LogObjects $objects = null, ?ChangeLog $log = null): LogListTable
{
return new LogListTable(
$log ?? Mockery::mock(ChangeLog::class),
$filters + ['ability' => '', 'user_id' => 0],
$toolLabels,
$objects,
self::NOW
);
}
public function testPrepareItemsUsesThePerPageScreenOptionAndPrimesUsersAndObjectsOnce(): void
{
$_GET['paged'] = '3';
Functions\when('get_user_option')->alias(static fn (string $key) => $key === 'lit_mcp_log_per_page' ? '30' : false);
$rows = [
['id' => '2', 'user_id' => '3', 'object_type' => 'page', 'object_id' => '12'],
['id' => '1', 'user_id' => '3', 'object_type' => 'page', 'object_id' => '13'],
['id' => '0', 'user_id' => '4', 'object_type' => 'option', 'object_id' => 'blogname'],
];
$log = Mockery::mock(ChangeLog::class);
$log->expects('query')
->with(['ability' => 'lit/content-update', 'user_id' => 0], 30, 60)
->andReturn(['rows' => $rows, 'total' => 120]);
Functions\when('post_type_exists')->justReturn(true);
Functions\expect('cache_users')->once()->with([3, 4]);
Functions\expect('_prime_post_caches')->once()->with([12, 13], false, false);
$table = $this->table(['ability' => 'lit/content-update'], [], new LogObjects(), $log);
$table->prepare_items();
self::assertSame($rows, $table->items);
self::assertSame(['total_items' => 120, 'per_page' => 30], $table->pagination_args());
}
public function testPrepareItemsDefaultsToTwentyPerPage(): void
{
$log = Mockery::mock(ChangeLog::class);
$log->expects('query')->with(['ability' => '', 'user_id' => 0], 20, 0)->andReturn(['rows' => [], 'total' => 0]);
$table = $this->table([], [], null, $log);
$table->prepare_items();
self::assertSame(['total_items' => 0, 'per_page' => 20], $table->pagination_args());
}
public function testColumnsPutTheChangeFirstAsTheUnhideablePrimaryColumn(): void
{
$table = $this->table();
self::assertSame(
['title' => 'Änderung', 'object' => 'Objekt', 'user' => 'Wer', 'created_at' => 'Zeit'],
$table->get_columns()
);
// "title" like core's own primary columns: WordPress never offers it for hiding (WP_Screen).
self::assertSame('title', $this->invoke($table, 'get_default_primary_column_name'));
}
public function testChangeColumnShowsTheSummaryAndTheToolsHumanLabel(): void
{
$table = $this->table([], ['lit/media-upload-from-url' => 'Datei von URL <importieren>']);
$html = $this->column($table, ['summary' => 'Datei <b>X</b> importiert', 'ability' => 'lit/media-upload-from-url', 'result' => 'ok'], 'title');
self::assertStringContainsString('Datei &lt;b&gt;X&lt;/b&gt; importiert', $html);
self::assertStringContainsString(
'<span class="lit-mcp-muted" title="lit/media-upload-from-url">Datei von URL &lt;importieren&gt;</span>',
$html
);
self::assertStringNotContainsString('lit-mcp-badge', $html);
}
public function testChangeColumnFallsBackToTheToolNameForAnUnknownTool(): void
{
$html = $this->column($this->table(), ['summary' => 'x', 'ability' => 'lit/gone<', 'result' => 'ok'], 'title');
self::assertStringContainsString('<span class="lit-mcp-muted">lit/gone&lt;</span>', $html);
}
/** @dataProvider notOk */
public function testANotOkResultGetsABadgeAndAMarkedRow(string $result, string $class, string $word): void
{
$table = $this->table();
$html = $this->column($table, ['summary' => 'x', 'ability' => 'lit/a', 'result' => $result], 'title');
self::assertStringContainsString('<span class="lit-mcp-badge lit-mcp-badge-' . $class . '">' . $word . '</span>', $html);
ob_start();
$table->single_row(['result' => $result, 'summary' => 'x', 'ability' => 'lit/a']);
self::assertStringStartsWith('<tr class="lit-mcp-row-' . $class . '">', (string) ob_get_clean());
}
/** @return list<list<string>> */
public static function notOk(): array
{
return [
['partial', 'warning', 'teilweise'],
['refused', 'warning', 'abgelehnt'],
['failed', 'error', 'fehlgeschlagen'],
['<b>kaputt', 'error', 'unbekannt'],
];
}
public function testAnOkRowIsNotMarked(): void
{
ob_start();
$this->table()->single_row(['result' => 'ok', 'summary' => 'x', 'ability' => 'lit/a']);
self::assertStringStartsWith('<tr>', (string) ob_get_clean());
}
public function testRowActionsOfferTheRevisionAndTheObjectOnlyWhereTheyExist(): void
{
$objects = Mockery::mock(LogObjects::class);
$objects->allows('describe')->with('page', '12')->andReturn(['html' => 'Seite: X', 'url' => 'https://kunde.de/wp-admin/post.php?post=12&action=edit']);
$objects->allows('describe')->with('option', 'blogname')->andReturn(['html' => 'Einstellung: Titel', 'url' => '']);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 811, 'post_type' => 'revision', 'post_parent' => 12]));
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $cap === 'edit_post' && $id === 12);
$table = $this->table([], [], $objects);
$both = $this->rowActions($table, ['object_type' => 'page', 'object_id' => '12', 'revision_id' => '811']);
self::assertStringContainsString('<a href="https://kunde.de/wp-admin/revision.php?revision=811">Revision ansehen</a>', $both);
self::assertStringContainsString('<a href="https://kunde.de/wp-admin/post.php?post=12&action=edit">Objekt öffnen</a>', $both);
$none = $this->rowActions($table, ['object_type' => 'option', 'object_id' => 'blogname', 'revision_id' => null]);
self::assertStringNotContainsString('row-actions', $none);
self::assertStringContainsString('toggle-row', $none, 'the mobile toggle stays');
self::assertSame('', $this->invoke($table, 'handle_row_actions', ['object_type' => 'option'], 'user', 'title'));
}
public function testRevisionActionNeedsEditRightsOnTheParentPost(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 811, 'post_type' => 'revision', 'post_parent' => 12]));
Functions\when('current_user_can')->justReturn(false);
$html = $this->rowActions($this->table(), ['object_type' => 'x', 'object_id' => '1', 'revision_id' => '811']);
self::assertStringNotContainsString('revision.php', $html);
}
public function testObjectColumnUsesTheHumanObjectDescription(): void
{
$objects = Mockery::mock(LogObjects::class);
$objects->expects('describe')->with('page', '12')->andReturn(['html' => 'Seite: <a href="u">X</a>', 'url' => 'u']);
self::assertSame('Seite: <a href="u">X</a>', $this->column($this->table([], [], $objects), ['object_type' => 'page', 'object_id' => '12'], 'object'));
}
public function testUserColumnShowsTheNameAndTheShortenedClientWithFullNameAsTitle(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan <b>']);
$client = 'lit-mcp-integration-test-client-<script>';
$html = $this->column($this->table(), ['user_id' => 3, 'client' => $client], 'user');
self::assertStringStartsWith('Jan &lt;b&gt;<br>', $html);
self::assertStringContainsString('title="lit-mcp-integration-test-client-&lt;script&gt;"', $html);
self::assertStringContainsString('>lit-mcp-integration-t…</span>', $html);
self::assertStringNotContainsString('<script>', $html);
}
public function testUserColumnFallsBackToTheIdAndOmitsAnEmptyClient(): void
{
self::assertSame('#7', $this->column($this->table(), ['user_id' => 7, 'client' => ''], 'user'));
self::assertSame(
'#7<br><span class="lit-mcp-muted" title="Claude">Claude</span>',
$this->column($this->table(), ['user_id' => 7, 'client' => 'Claude'], 'user')
);
}
public function testTimeIsRelativeWithinADay(): void
{
Functions\when('human_time_diff')->alias(static fn (int $from, int $to): string => (($to - $from) / 60) . ' Minuten');
$html = $this->column($this->table(), ['created_at' => '2026-09-26 16:44:13'], 'created_at');
self::assertStringContainsString('>vor 5 Minuten</time>', $html);
self::assertStringContainsString('datetime="2026-09-26T16:44:13+00:00"', $html);
self::assertStringContainsString('title="j. F Y@' . (self::NOW - 300) . ' H:i@' . (self::NOW - 300) . ' (UTC: 2026-09-26 16:44:13)"', $html);
}
public function testOlderTimesShowTheSiteDateAndTime(): void
{
$ts = gmmktime(10, 39, 13, 9, 24, 2026);
$html = $this->column($this->table(), ['created_at' => '2026-09-24 10:39:13'], 'created_at');
self::assertStringContainsString('>j. F Y@' . $ts . ' um H:i@' . $ts . '</time>', $html);
self::assertSame('–', $this->column($this->table(), ['created_at' => 'kaputt'], 'created_at'));
}
public function testEmptyStateWithoutFilters(): void
{
ob_start();
$this->table()->no_items();
self::assertSame('Noch keine Änderungen protokolliert.', (string) ob_get_clean());
}
public function testEmptyStateWithFiltersOffersTheReset(): void
{
ob_start();
$this->table(['result' => 'partial'])->no_items();
self::assertSame(
'Keine Einträge für diese Filter. <a href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=protokoll">Filter zurücksetzen</a>',
(string) ob_get_clean()
);
}
/** @param array<string, mixed> $item */
private function rowActions(LogListTable $table, array $item): string
{
return (string) $this->invoke($table, 'handle_row_actions', $item, 'title', 'title');
}
private function invoke(LogListTable $table, string $method, mixed ...$args): mixed
{
return (new \ReflectionMethod($table, $method))->invoke($table, ...$args);
}
/** @param array<string, mixed> $item */
private function column(LogListTable $table, array $item, string $column): string
{
$method = 'column_' . $column;
if (method_exists($table, $method)) {
return (string) $this->invoke($table, $method, $item);
}
return (string) $this->invoke($table, 'column_default', $item, $column);
}
}