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:
@@ -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('<script>', $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 <b>', $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Admin\LogTab;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Limits;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
|
||||
final class LogTabTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($_GET['ability'], $_GET['user_id'], $_GET['result'], $_GET['date_from'], $_GET['date_to']);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testFiltersAreReadSanitized(): void
|
||||
{
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
$_GET['ability'] = 'lit/content-update';
|
||||
$_GET['user_id'] = '3x';
|
||||
|
||||
self::assertSame(['ability' => 'lit/content-update', 'user_id' => 3], $tab->filters());
|
||||
|
||||
$_GET['ability'] = 'lit/x"><script>';
|
||||
self::assertSame('', $tab->filters()['ability']);
|
||||
}
|
||||
|
||||
public function testExtraFiltersWhitelistResultAndDateRange(): void
|
||||
{
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
|
||||
self::assertSame(
|
||||
['result' => '', 'date_from' => '', 'date_to' => ''],
|
||||
$tab->extraFilters(),
|
||||
'absent GET params sanitize to empty strings'
|
||||
);
|
||||
|
||||
$_GET['result'] = 'partial';
|
||||
$_GET['date_from'] = '2026-09-01';
|
||||
$_GET['date_to'] = '2026-09-25';
|
||||
|
||||
self::assertSame(
|
||||
['result' => 'partial', 'date_from' => '2026-09-01', 'date_to' => '2026-09-25'],
|
||||
$tab->extraFilters()
|
||||
);
|
||||
}
|
||||
|
||||
public function testExtraFiltersRejectUnknownResultAndMalformedDates(): void
|
||||
{
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
|
||||
$_GET['result'] = 'ok"><script>';
|
||||
$_GET['date_from'] = 'not-a-date';
|
||||
$_GET['date_to'] = '2026-13-99'; // wrong shape is enough to reject; no calendar validation needed here.
|
||||
|
||||
self::assertSame(['result' => '', 'date_from' => '', 'date_to' => '2026-13-99'], $tab->extraFilters());
|
||||
}
|
||||
|
||||
public function testClearNeedsNonceAndRights(): void
|
||||
{
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
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));
|
||||
Functions\expect('check_admin_referer')->once()->with('lit_mcp_clear_log');
|
||||
Functions\expect('wp_safe_redirect')->once()->with(Mockery::on(
|
||||
static fn (string $url): bool => str_contains($url, 'tab=protokoll') && str_contains($url, 'lit_mcp_notice=cleared')
|
||||
));
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('clear')->once();
|
||||
$terminated = false;
|
||||
|
||||
(new LogTab($log, new Limits(), static function () use (&$terminated): void {
|
||||
$terminated = true;
|
||||
}))->handleClear();
|
||||
|
||||
self::assertTrue($terminated);
|
||||
}
|
||||
|
||||
public function testClearRequiresManageOptions(): void
|
||||
{
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\expect('current_user_can')->once()->with('manage_options')->andReturn(false);
|
||||
Functions\expect('wp_die')->once()->andThrow(new RuntimeException('died'));
|
||||
Functions\expect('check_admin_referer')->never();
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('clear')->never();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
(new LogTab($log, new Limits()))->handleClear();
|
||||
}
|
||||
|
||||
public function testRegistersTheClearAction(): void
|
||||
{
|
||||
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
||||
Functions\expect('add_action')->once()->with('admin_post_lit_mcp_clear_log', [$tab, 'handleClear']);
|
||||
|
||||
$tab->registerActions();
|
||||
}
|
||||
|
||||
public function testRenderShowsFiltersAndAConfirmProtectedClearButton(): void
|
||||
{
|
||||
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',
|
||||
string $name = 'submit',
|
||||
bool $wrap = true,
|
||||
$other = ''
|
||||
): void {
|
||||
$attrs = is_array($other) ? implode(' ', array_map(
|
||||
static fn ($k, $v): string => sprintf('%s="%s"', $k, $v),
|
||||
array_keys($other),
|
||||
$other
|
||||
)) : (string) $other;
|
||||
printf('<button type="submit" name="%s" %s>%s</button>', esc_attr($name), $attrs, esc_html($text));
|
||||
});
|
||||
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
|
||||
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
||||
});
|
||||
Functions\when('wp_dropdown_users')->alias(static function (array $args): void {
|
||||
echo '<select name="' . $args['name'] . '"><option>Alle</option></select>';
|
||||
});
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||
Functions\when('get_option')->justReturn([]);
|
||||
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->allows('abilities')->andReturn(['lit/content-update', 'lit/x"><script>']);
|
||||
$log->allows('query')->andReturn(['rows' => [], 'total' => 0]);
|
||||
|
||||
ob_start();
|
||||
(new LogTab($log, new Limits()))->render();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -126,4 +126,70 @@ final class ChangeLogTest extends TestCase
|
||||
|
||||
self::assertSame(3, (new ChangeLog($db))->purgeOlderThan(90));
|
||||
}
|
||||
|
||||
public function testQueryFiltersAndPaginatesNewestFirst(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->expects('get_var')
|
||||
->with(Mockery::on(static fn (string $sql): bool => $sql
|
||||
=== "SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1 AND ability = 'lit/content-update'"
|
||||
. " AND user_id = 3"))
|
||||
->andReturn('12');
|
||||
$db->expects('get_results')
|
||||
->with(
|
||||
Mockery::on(static fn (string $sql): bool => str_ends_with($sql, 'ORDER BY id DESC LIMIT 50 OFFSET 50')),
|
||||
'ARRAY_A'
|
||||
)
|
||||
->andReturn([['id' => '7', 'ability' => 'lit/content-update']]);
|
||||
|
||||
$result = (new ChangeLog($db))->query(['ability' => 'lit/content-update', 'user_id' => 3], 50, 50);
|
||||
|
||||
self::assertSame(12, $result['total']);
|
||||
self::assertSame('7', $result['rows'][0]['id']);
|
||||
}
|
||||
|
||||
public function testQueryFiltersByResultAndDateRange(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->expects('get_var')
|
||||
->with(Mockery::on(static fn (string $sql): bool => $sql
|
||||
=== "SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1 AND result = 'partial'"
|
||||
. " AND created_at >= '2026-09-01 00:00:00' AND created_at <= '2026-09-25 23:59:59'"))
|
||||
->andReturn('2');
|
||||
$db->expects('get_results')->andReturn([]);
|
||||
|
||||
$result = (new ChangeLog($db))->query(
|
||||
['result' => 'partial', 'date_from' => '2026-09-01', 'date_to' => '2026-09-25'],
|
||||
50,
|
||||
0
|
||||
);
|
||||
|
||||
self::assertSame(2, $result['total']);
|
||||
self::assertSame([], $result['rows']);
|
||||
}
|
||||
|
||||
public function testQueryWithoutFiltersUsesTheBaseCondition(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->expects('get_var')
|
||||
->with('SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1')
|
||||
->andReturn('0');
|
||||
$db->expects('get_results')->andReturn(null);
|
||||
|
||||
$result = (new ChangeLog($db))->query([], 50, 0);
|
||||
|
||||
self::assertSame(0, $result['total']);
|
||||
self::assertSame([], $result['rows']);
|
||||
}
|
||||
|
||||
public function testAbilitiesAndClear(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->expects('get_col')->with('SELECT DISTINCT ability FROM wp_lit_mcp_log ORDER BY ability')->andReturn(['lit/a']);
|
||||
$db->expects('query')->with('DELETE FROM wp_lit_mcp_log')->andReturn(4);
|
||||
$log = new ChangeLog($db);
|
||||
|
||||
self::assertSame(['lit/a'], $log->abilities());
|
||||
$log->clear();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user