feat: change log table with retention cron and limits option
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,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||||
use LIT\MCP\Limits;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class ChangeLogCleanupTest extends TestCase
|
||||
{
|
||||
public function testRunPurgesWithTheConfiguredRetention(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn(['log_retention_days' => 30]);
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('purgeOlderThan')->with(30)->andReturn(0);
|
||||
|
||||
(new ChangeLogCleanup($log, new Limits()))->run();
|
||||
}
|
||||
|
||||
public function testEnsureScheduledSchedulesADailyEventOnce(): void
|
||||
{
|
||||
Functions\when('wp_next_scheduled')->justReturn(false);
|
||||
Functions\expect('wp_schedule_event')->once()->with(Mockery::type('int'), 'daily', 'lit_mcp_log_cleanup');
|
||||
|
||||
(new ChangeLogCleanup(Mockery::mock(ChangeLog::class), new Limits()))->ensureScheduled();
|
||||
}
|
||||
|
||||
public function testEnsureScheduledDoesNothingWhenAlreadyScheduled(): void
|
||||
{
|
||||
Functions\when('wp_next_scheduled')->justReturn(1790330400);
|
||||
Functions\expect('wp_schedule_event')->never();
|
||||
|
||||
(new ChangeLogCleanup(Mockery::mock(ChangeLog::class), new Limits()))->ensureScheduled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class ChangeLogInstallerTest extends TestCase
|
||||
{
|
||||
public function testInstallCreatesTheTableAndStoresTheVersion(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
Functions\expect('dbDelta')->once()->with(Mockery::on(static function (string $sql): bool {
|
||||
return str_contains($sql, 'CREATE TABLE wp_lit_mcp_log (')
|
||||
&& str_contains($sql, 'client varchar(191)')
|
||||
&& str_contains($sql, 'revision_id bigint(20) unsigned DEFAULT NULL')
|
||||
&& str_contains($sql, 'snapshot_id varchar(64) DEFAULT NULL')
|
||||
&& str_contains($sql, 'PRIMARY KEY (id)');
|
||||
}));
|
||||
Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1');
|
||||
|
||||
(new ChangeLogInstaller(new ChangeLog($db)))->install();
|
||||
}
|
||||
|
||||
public function testMaybeInstallSkipsWhenTheVersionIsCurrent(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn('1');
|
||||
Functions\expect('dbDelta')->never();
|
||||
|
||||
(new ChangeLogInstaller(new ChangeLog(Mockery::mock(\wpdb::class)->makePartial())))->maybeInstall();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Lifecycle;
|
||||
use Mockery;
|
||||
|
||||
final class LifecycleTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($GLOBALS['wpdb']);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testActivateInstallsTheTableAndSchedulesTheCleanup(): void
|
||||
{
|
||||
$GLOBALS['wpdb'] = Mockery::mock(\wpdb::class)->makePartial();
|
||||
Functions\expect('dbDelta')->once();
|
||||
Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1');
|
||||
Functions\when('wp_next_scheduled')->justReturn(false);
|
||||
Functions\expect('wp_schedule_event')->once();
|
||||
|
||||
Lifecycle::activate();
|
||||
}
|
||||
|
||||
public function testDeactivateUnschedulesTheCleanup(): void
|
||||
{
|
||||
Functions\expect('wp_clear_scheduled_hook')->once()->with('lit_mcp_log_cleanup');
|
||||
|
||||
Lifecycle::deactivate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Limits;
|
||||
|
||||
final class LimitsTest extends TestCase
|
||||
{
|
||||
public function testDefaultsWhenOptionIsMissing(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn([]);
|
||||
$limits = new Limits();
|
||||
|
||||
self::assertSame(5 * 1024 * 1024, $limits->uploadBytes());
|
||||
self::assertSame(5, $limits->uploadMegabytes());
|
||||
self::assertSame(90, $limits->logRetentionDays());
|
||||
self::assertSame(20, $limits->snapshotCount());
|
||||
}
|
||||
|
||||
public function testStoredValuesAreClamped(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn(['upload_mb' => 500, 'log_retention_days' => 0, 'snapshot_count' => '7']);
|
||||
|
||||
self::assertSame(['upload_mb' => 100, 'log_retention_days' => 1, 'snapshot_count' => 7], (new Limits())->all());
|
||||
}
|
||||
|
||||
public function testCorruptOptionFallsBackToDefaults(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn('garbage');
|
||||
|
||||
self::assertSame(Limits::DEFAULTS, (new Limits())->all());
|
||||
}
|
||||
|
||||
public function testSanitizeReturnsACompleteClampedArray(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['upload_mb' => 12, 'log_retention_days' => 90, 'snapshot_count' => 100],
|
||||
Limits::sanitize(['upload_mb' => '12', 'log_retention_days' => 'x', 'snapshot_count' => 1000])
|
||||
);
|
||||
self::assertSame(Limits::DEFAULTS, Limits::sanitize(null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user