diff --git a/src/ChangeLog/ChangeLog.php b/src/ChangeLog/ChangeLog.php index 73449f8..ef6160f 100644 --- a/src/ChangeLog/ChangeLog.php +++ b/src/ChangeLog/ChangeLog.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\ChangeLog; +use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Server\RequestContext; /** @@ -44,9 +45,9 @@ class ChangeLog 'created_at' => current_time('mysql', true), 'user_id' => get_current_user_id(), 'client' => $this->sanitizedClientName(), - 'ability' => $ability, - 'object_type' => $objectType, - 'object_id' => $objectId, + 'ability' => mb_substr($ability, 0, 100), + 'object_type' => mb_substr($objectType, 0, 50), + 'object_id' => mb_substr($objectId, 0, 191), 'summary' => mb_substr($summary, 0, 500), 'revision_id' => $revisionId, 'snapshot_id' => $snapshotId, @@ -64,6 +65,27 @@ class ChangeLog return (int) $wpdb->insert_id; } + /** + * Logs a write that failed and could not be rolled back completely (result "partial"). + * Never throws: the caller is already reporting an error, a log failure only goes to + * the PHP error log. + */ + public function recordPartial( + string $ability, + string $objectType, + string $objectId, + string $summary, + ?int $revisionId = null + ): void { + $ignored = []; + FollowUp::run( + fn () => $this->record($ability, $objectType, $objectId, $summary, $revisionId, null, 'partial'), + $ability . ' change log', + '', + $ignored + ); + } + public function purgeOlderThan(int $days): int { $wpdb = $this->db(); diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index caf158f..feb590d 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -204,12 +204,6 @@ final class ContentCreateAbility implements Ability private function logPartial(string $postType, string $postId, string $summary): void { - $ignored = []; - FollowUp::run( - fn () => $this->log->record($this->name(), $postType, $postId, $summary, null, null, 'partial'), - $this->name() . ' change log', - '', - $ignored - ); + $this->log->recordPartial($this->name(), $postType, $postId, $summary); } } diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index 31a74cb..0ac8692 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -247,20 +247,12 @@ final class ContentUpdateAbility implements Ability throw new WriteFailed(self::REASON, true, $postId, $revisionId, $cause, $detail); } - $ignored = []; - FollowUp::run( - fn () => $this->log->record( - $this->name(), - (string) $post->post_type, - (string) $postId, - sprintf('"%s": Änderung nur teilweise zurückgenommen', $post->post_title), - $revisionId, - null, - 'partial' - ), - $this->name() . ' change log', - '', - $ignored + $this->log->recordPartial( + $this->name(), + (string) $post->post_type, + (string) $postId, + sprintf('"%s": Änderung nur teilweise zurückgenommen', $post->post_title), + $revisionId ); throw new WriteFailed( self::REASON, diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 7f2c403..2549d54 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -283,20 +283,12 @@ final class PostReplaceWithCopyAbility implements Ability try { $this->cloner->restore($original, $snapshot, $parts); } catch (Throwable $e) { - $ignored = []; - FollowUp::run( - fn () => $this->log->record( - $this->name(), - (string) $original->post_type, - (string) $originalId, - sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title), - $revisionId, - null, - 'partial' - ), - $this->name() . ' change log', - '', - $ignored + $this->log->recordPartial( + $this->name(), + (string) $original->post_type, + (string) $originalId, + sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title), + $revisionId ); throw new WriteFailed( self::REASON, diff --git a/tests/Unit/ChangeLog/ChangeLogTest.php b/tests/Unit/ChangeLog/ChangeLogTest.php index 1e1df8a..e757dcb 100644 --- a/tests/Unit/ChangeLog/ChangeLogTest.php +++ b/tests/Unit/ChangeLog/ChangeLogTest.php @@ -67,6 +67,53 @@ final class ChangeLogTest extends TestCase 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(); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php index 09ab3b3..c104a55 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php @@ -134,8 +134,8 @@ final class ContentCreateAbilityTest extends TestCase Functions\when('wp_insert_post')->justReturn(42); Functions\when('wp_delete_post')->justReturn(false); $log = Mockery::mock(ChangeLog::class); - $log->expects('record') - ->with('lit/content-create', 'lit_person', '42', Mockery::type('string'), null, null, 'partial') + $log->expects('recordPartial') + ->with('lit/content-create', 'lit_person', '42', Mockery::type('string')) ->andReturn(9); $failure = $this->execute($writer, $log); @@ -153,7 +153,7 @@ final class ContentCreateAbilityTest extends TestCase Functions\when('wp_insert_post')->justReturn(42); Functions\when('wp_delete_post')->justReturn(new WP_Post(['ID' => 42])); $log = Mockery::mock(ChangeLog::class); - $log->expects('record')->andReturn(9); + $log->expects('recordPartial'); $failure = $this->execute($writer, $log); @@ -168,8 +168,8 @@ final class ContentCreateAbilityTest extends TestCase throw new RuntimeException('save_post hook kaputt'); }); $log = Mockery::mock(ChangeLog::class); - $log->expects('record') - ->with('lit/content-create', 'lit_person', '', Mockery::type('string'), null, null, 'partial') + $log->expects('recordPartial') + ->with('lit/content-create', 'lit_person', '', Mockery::type('string')) ->andReturn(9); $failure = $this->execute($writer, $log); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php index 12ef0f3..d8f0f09 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php @@ -261,8 +261,8 @@ final class ContentUpdateAbilityTest extends TestCase $this->captureUpdates($updates); Functions\when('wp_revisions_enabled')->justReturn(false); $log = Mockery::mock(ChangeLog::class); - $log->expects('record') - ->with('lit/content-update', 'page', '5', Mockery::type('string'), 811, null, 'partial') + $log->expects('recordPartial') + ->with('lit/content-update', 'page', '5', Mockery::type('string'), 811) ->andReturn(12); $failure = $this->executeExpectingFailure($writer, $input, $log); diff --git a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php index 044a969..a6bc78c 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php @@ -228,8 +228,8 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $cloner->expects('restore')->with($this->original, self::SNAPSHOT, PostCloner::PARTS)->andThrow(new RuntimeException('Restore incomplete: terms')); Functions\expect('wp_trash_post')->never(); $log = Mockery::mock(ChangeLog::class); - $log->expects('record') - ->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900, null, 'partial') + $log->expects('recordPartial') + ->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900) ->andReturn(12); try {