feat: change log tab with filters, revision links and clear button

ChangeLog gains query()/abilities()/clear() (filters: ability, user,
result, date range - all optional and additive). LogTab/LogListTable
render the "Protokoll" admin tab: WP_List_Table with pagination, links
to the post/attachment/nav_menu edit screen by object_type, a revision
link shown only when the user may edit the revision's parent post, and
a nonce+manage_options-gated "Protokoll leeren" with a JS confirmation
and highlighted 'partial' rows.

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:
2026-09-26 04:12:13 +02:00
co-authored by Claude Opus 5.5
parent 9104989c43
commit 522f5bbd19
9 changed files with 940 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\LogListTable;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
use WP_Term;
final class LogListTableTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
// Harmless defaults so single_row()'s full column pass doesn't blow up in tests
// that only care about a subset of columns; overridden per test where needed.
Functions\when('get_userdata')->justReturn(false);
Functions\when('post_type_exists')->justReturn(false);
Functions\when('get_post')->justReturn(null);
Functions\when('get_term')->justReturn(null);
Functions\when('current_user_can')->justReturn(false);
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
}
protected function tearDown(): void
{
unset($_GET['paged']);
parent::tearDown();
}
public function testPrepareItemsQueriesTheCurrentPageAndSetsPagination(): void
{
$_GET['paged'] = '3';
$log = Mockery::mock(ChangeLog::class);
$log->expects('query')
->with(['ability' => 'lit/content-update', 'user_id' => 0], 50, 100)
->andReturn(['rows' => [['id' => '1']], 'total' => 120]);
$table = new LogListTable($log, ['ability' => 'lit/content-update', 'user_id' => 0]);
$table->prepare_items();
self::assertSame([['id' => '1']], $table->items);
self::assertSame(['total_items' => 120, 'per_page' => 50], $table->pagination_args());
}
public function testColumnDefaultEscapesUntrustedClientAndSummaryText(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$item = ['client' => 'Claude <script>alert(1)</script>', 'summary' => '<b>böse</b> Zusammenfassung'];
self::assertStringNotContainsString('<script>', $this->columnDefault($table, $item, 'client'));
self::assertStringContainsString('&lt;script&gt;', $this->columnDefault($table, $item, 'client'));
self::assertStringNotContainsString('<b>', $this->columnDefault($table, $item, 'summary'));
}
public function testColumnDefaultShowsUserDisplayNameOrFallback(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan <b>']);
$withUser = $this->columnDefault($table, ['user_id' => 3], 'user');
self::assertStringContainsString('Jan &lt;b&gt;', $withUser);
}
public function testColumnDefaultUserFallsBackToIdWhenUserIsGone(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
Functions\when('get_userdata')->justReturn(false);
self::assertSame('#7', $this->columnDefault($table, ['user_id' => 7], 'user'));
}
public function testColumnDefaultLinksTheObjectForAKnownPostType(): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=12&action=edit');
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->columnDefault($table, ['object_type' => 'page', 'object_id' => '12'], 'object');
self::assertStringContainsString('href="https://kunde.de/wp-admin/post.php?post=12&action=edit"', $html);
self::assertStringContainsString('page 12', $html);
}
public function testColumnDefaultShowsPlainLabelWhenGetEditPostLinkRefusesIt(): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('get_edit_post_link')->justReturn(null);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->columnDefault($table, ['object_type' => 'page', 'object_id' => '12'], 'object');
self::assertSame('page 12', $html);
}
public function testColumnDefaultLinksAnAttachmentToTheMediaEditScreen(): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5]));
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->columnDefault($table, ['object_type' => 'attachment', 'object_id' => '5'], 'object');
self::assertStringContainsString('href="https://kunde.de/wp-admin/upload.php?item=5"', $html);
}
public function testColumnDefaultShowsPlainLabelForADeletedAttachment(): void
{
Functions\when('get_post')->justReturn(null);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame('attachment 5', $this->columnDefault($table, ['object_type' => 'attachment', 'object_id' => '5'], 'object'));
}
public function testColumnDefaultLinksANavMenuToTheMenusScreen(): void
{
Functions\when('get_term')->justReturn(new WP_Term(['term_id' => 3, 'taxonomy' => 'nav_menu']));
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->columnDefault($table, ['object_type' => 'nav_menu', 'object_id' => '3'], 'object');
self::assertStringContainsString('href="https://kunde.de/wp-admin/nav-menus.php?menu=3"', $html);
}
/** @dataProvider unlinkableObjectTypes */
public function testColumnDefaultNeverLinksSettingsOrMenuLocations(string $type, string $id): void
{
Functions\when('post_type_exists')->justReturn(false);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame(trim($type . ' ' . $id), $this->columnDefault($table, ['object_type' => $type, 'object_id' => $id], 'object'));
}
/** @return list<list<string>> */
public static function unlinkableObjectTypes(): array
{
return [
['option', 'blogname,blogdescription'],
['menu_location', 'primary'],
['unknown', '42'],
];
}
public function testRevisionColumnShowsADashWithoutARevision(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame('–', $this->columnDefault($table, ['revision_id' => 0], 'revision'));
}
public function testRevisionColumnLinksOnlyWhenTheUserCanEditTheParentPost(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 811, 'post_type' => 'revision', 'post_parent' => 12]));
Functions\when('current_user_can')->justReturn(true);
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->columnDefault($table, ['revision_id' => 811], 'revision');
self::assertStringContainsString('href="https://kunde.de/wp-admin/revision.php?revision=811"', $html);
}
public function testRevisionColumnHidesTheLinkWithoutEditPermission(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 811, 'post_type' => 'revision', 'post_parent' => 12]));
Functions\when('current_user_can')->justReturn(false);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame('–', $this->columnDefault($table, ['revision_id' => 811], 'revision'));
}
public function testSingleRowHighlightsPartialResults(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
ob_start();
$table->single_row(['result' => 'partial']);
$partial = (string) ob_get_clean();
ob_start();
$table->single_row(['result' => 'ok']);
$ok = (string) ob_get_clean();
self::assertStringContainsString('<tr style=', $partial);
self::assertSame('<tr>', substr($ok, 0, 4));
}
/** @param array<string, mixed> $item */
private function columnDefault(LogListTable $table, array $item, string $column): string
{
return (string) (new \ReflectionMethod($table, 'column_default'))->invoke($table, $item, $column);
}
}