ChangeLog::record() löst nach jeder erfolgreichen oder teilweise gespeicherten Änderung lit_mcp_changed(ability, objectType, objectId, logId) aus, nie bei abgelehnten. Die Werte sind gekürzt wie im Protokoll; Ausnahmen von Listenern werden protokolliert und ändern das Ergebnis des Tools nicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
12 KiB
PHP
299 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
|
|
|
use Brain\Monkey\Actions;
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\ChangeLog\ChangeLog;
|
|
use LIT\MCP\Server\RequestContext;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Mockery;
|
|
|
|
final class ChangeLogTest extends TestCase
|
|
{
|
|
protected function tearDown(): void
|
|
{
|
|
RequestContext::reset();
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function testRecordStoresReferencesAndReturnsTheId(): void
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
|
Functions\when('get_current_user_id')->justReturn(3);
|
|
RequestContext::reset(true, 'Claude');
|
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
|
$db->expects('insert')
|
|
->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool {
|
|
return $row['created_at'] === '2026-09-25 08:00:00'
|
|
&& $row['user_id'] === 3
|
|
&& $row['client'] === 'Claude'
|
|
&& $row['ability'] === 'lit/content-update'
|
|
&& $row['object_type'] === 'page'
|
|
&& $row['object_id'] === '12'
|
|
&& $row['summary'] === 'Seite geändert'
|
|
&& $row['revision_id'] === 811
|
|
&& $row['snapshot_id'] === null
|
|
&& $row['result'] === 'ok';
|
|
}), Mockery::type('array'))
|
|
->andReturnUsing(static function () use ($db): int {
|
|
$db->insert_id = 4711;
|
|
return 1;
|
|
});
|
|
|
|
$id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'Seite geändert', 811);
|
|
|
|
self::assertSame(4711, $id);
|
|
}
|
|
|
|
public function testRecordReturnsNullWhenTheInsertFails(): void
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
|
Functions\when('get_current_user_id')->justReturn(3);
|
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
|
$db->allows('insert')->andReturn(false);
|
|
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
|
$previous = ini_set('error_log', $log);
|
|
|
|
try {
|
|
$id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'x');
|
|
} finally {
|
|
ini_set('error_log', (string) $previous);
|
|
}
|
|
|
|
self::assertNull($id);
|
|
self::assertStringContainsString('change log insert failed', (string) file_get_contents($log));
|
|
unlink($log);
|
|
}
|
|
|
|
private function dbInserting(int|false $outcome): \wpdb
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
|
Functions\when('get_current_user_id')->justReturn(3);
|
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
|
$db->allows('insert')->andReturnUsing(static function () use ($db, $outcome): int|false {
|
|
$db->insert_id = 77;
|
|
|
|
return $outcome;
|
|
});
|
|
|
|
return $db;
|
|
}
|
|
|
|
public function testAnOkEntryAnnouncesTheChange(): void
|
|
{
|
|
$seen = [];
|
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void {
|
|
$seen = $args;
|
|
});
|
|
|
|
(new ChangeLog($this->dbInserting(1)))->record('lit/content-update', 'page', '12', 'x');
|
|
|
|
self::assertSame(['lit/content-update', 'page', '12', 77], $seen);
|
|
}
|
|
|
|
public function testARefusedEntryDoesNotAnnounceAChange(): void
|
|
{
|
|
Actions\expectDone('lit_mcp_changed')->never();
|
|
|
|
(new ChangeLog($this->dbInserting(1)))->record('lit/x', 'page', '1', 'x', result: 'refused');
|
|
$this->addToAssertionCount(1);
|
|
}
|
|
|
|
/** A partly executed change changed something: listeners (cache purge) must hear of it. */
|
|
public function testAPartialEntryAnnouncesTheChange(): void
|
|
{
|
|
$seen = [];
|
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void {
|
|
$seen = $args;
|
|
});
|
|
|
|
(new ChangeLog($this->dbInserting(1)))->record('lit/x', 'page', '1', 'x', result: 'partial');
|
|
|
|
self::assertSame(['lit/x', 'page', '1', 77], $seen);
|
|
}
|
|
|
|
/** Listeners get the same shortened values as the log row (ability 100, type 50, id 191). */
|
|
public function testTheAnnouncementCarriesTheShortenedValuesOfTheRow(): void
|
|
{
|
|
$seen = [];
|
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void {
|
|
$seen = $args;
|
|
});
|
|
|
|
(new ChangeLog($this->dbInserting(1)))
|
|
->record(str_repeat('a', 150), str_repeat('t', 80), str_repeat('ä', 300), 'x');
|
|
|
|
self::assertSame([str_repeat('a', 100), str_repeat('t', 50), str_repeat('ä', 191), 77], $seen);
|
|
}
|
|
|
|
/** The write happened even when its log row did not: listeners (cache purge) still need to know. */
|
|
public function testAFailedInsertStillAnnouncesTheChangeWithoutALogId(): void
|
|
{
|
|
$seen = [];
|
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void {
|
|
$seen = $args;
|
|
});
|
|
$previous = ini_set('error_log', (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'));
|
|
|
|
try {
|
|
$id = (new ChangeLog($this->dbInserting(false)))->record('lit/options', 'option', 'blogname', 'x');
|
|
} finally {
|
|
ini_set('error_log', (string) $previous);
|
|
}
|
|
|
|
self::assertNull($id);
|
|
self::assertSame(['lit/options', 'option', 'blogname', null], $seen);
|
|
}
|
|
|
|
public function testAThrowingListenerChangesNothingAndIsLogged(): void
|
|
{
|
|
Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (): void {
|
|
throw new \RuntimeException('listener broke');
|
|
});
|
|
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
|
$previous = ini_set('error_log', $log);
|
|
|
|
try {
|
|
$id = (new ChangeLog($this->dbInserting(1)))->record('lit/content-update', 'page', '12', 'x');
|
|
} finally {
|
|
ini_set('error_log', (string) $previous);
|
|
}
|
|
|
|
self::assertSame(77, $id);
|
|
self::assertStringContainsString('lit_mcp_changed failed', (string) file_get_contents($log));
|
|
self::assertStringContainsString('listener broke', (string) file_get_contents($log));
|
|
unlink($log);
|
|
}
|
|
|
|
public function testRecordTruncatesObjectIdAndAbilityToTheColumnSizes(): void
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
|
Functions\when('get_current_user_id')->justReturn(3);
|
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
|
$db->expects('insert')
|
|
->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool {
|
|
return $row['object_id'] === str_repeat('ä', 191) && $row['ability'] === str_repeat('ö', 100);
|
|
}), Mockery::type('array'))
|
|
->andReturn(1);
|
|
|
|
(new ChangeLog($db))->record(str_repeat('ö', 150), 'option', str_repeat('ä', 300), 'x');
|
|
}
|
|
|
|
public function testRecordPartialStoresResultPartial(): void
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
|
Functions\when('get_current_user_id')->justReturn(3);
|
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
|
$db->expects('insert')
|
|
->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool {
|
|
return $row['result'] === 'partial' && $row['revision_id'] === 7 && $row['object_id'] === '12';
|
|
}), Mockery::type('array'))
|
|
->andReturn(1);
|
|
|
|
(new ChangeLog($db))->recordPartial('lit/content-update', 'page', '12', 'teilweise', 7);
|
|
}
|
|
|
|
public function testRecordPartialNeverThrows(): void
|
|
{
|
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
|
Functions\when('get_current_user_id')->justReturn(3);
|
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
|
$db->allows('insert')->andThrow(new \RuntimeException('db gone'));
|
|
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
|
$previous = ini_set('error_log', $log);
|
|
|
|
try {
|
|
(new ChangeLog($db))->recordPartial('lit/menu-set-items', 'nav_menu', '4', 'teilweise');
|
|
} finally {
|
|
ini_set('error_log', (string) $previous);
|
|
}
|
|
|
|
self::assertStringContainsString('db gone', (string) file_get_contents($log));
|
|
unlink($log);
|
|
}
|
|
|
|
public function testPurgeDeletesRowsOlderThanTheRetention(): void
|
|
{
|
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
|
$db->expects('query')
|
|
->with(Mockery::on(static fn (string $sql): bool => str_starts_with(
|
|
$sql,
|
|
"DELETE FROM wp_lit_mcp_log WHERE created_at < '"
|
|
)))
|
|
->andReturn(3);
|
|
|
|
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 testAbilitiesCountAndClear(): 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('get_var')->with('SELECT COUNT(*) FROM wp_lit_mcp_log')->andReturn('7');
|
|
$db->expects('query')->with('DELETE FROM wp_lit_mcp_log')->andReturn(4);
|
|
$log = new ChangeLog($db);
|
|
|
|
self::assertSame(['lit/a'], $log->abilities());
|
|
self::assertSame(7, $log->count());
|
|
$log->clear();
|
|
}
|
|
}
|