Files
WP-LIT-MCP/tests/Unit/Admin/LogListTableTest.php
T
2026-09-30 01:54:36 +02:00

291 lines
13 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\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 &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()
);
}
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);
}
}