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
290 lines
12 KiB
PHP
290 lines
12 KiB
PHP
<?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],
|
||
null,
|
||
$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 <b>X</b> importiert', $html);
|
||
self::assertStringContainsString(
|
||
'<span class="lit-mcp-muted" title="lit/media-upload-from-url">Datei von URL <importieren></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<</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'],
|
||
['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 <b><br>', $html);
|
||
self::assertStringContainsString('title="lit-mcp-integration-test-client-<script>"', $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()
|
||
);
|
||
}
|
||
|
||
public function testTopNavRendersTheGivenFilters(): void
|
||
{
|
||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0], static function (): void {
|
||
echo '<select name="ability"></select>';
|
||
});
|
||
|
||
ob_start();
|
||
$table->display();
|
||
|
||
self::assertStringContainsString('<div class="alignleft actions"><select name="ability"></select></div>', (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);
|
||
}
|
||
}
|