From 563d9e8d158a960446ad6a3744c839fd548e5188 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:23:09 +0200 Subject: [PATCH] fix: status tools report a hook throwing during the write as partial_write publish/unpublish/schedule/trash run their WordPress call through StatusWrite: a throwing hook becomes WriteFailed (rollback incomplete, with post_id) and is logged with result partial instead of surfacing as internal_error. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Status/PostPublishAbility.php | 8 ++- .../WordPress/Status/PostScheduleAbility.php | 11 ++++- .../WordPress/Status/PostTrashAbility.php | 9 +++- .../WordPress/Status/PostUnpublishAbility.php | 8 ++- .../WordPress/Status/StatusWrite.php | 49 +++++++++++++++++++ .../Status/PostPublishAbilityTest.php | 22 +++++++++ .../Status/PostScheduleAbilityTest.php | 21 ++++++++ .../WordPress/Status/PostTrashAbilityTest.php | 21 ++++++++ .../Status/PostUnpublishAbilityTest.php | 21 ++++++++ 9 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 src/Integrations/WordPress/Status/StatusWrite.php diff --git a/src/Integrations/WordPress/Status/PostPublishAbility.php b/src/Integrations/WordPress/Status/PostPublishAbility.php index 3c31632..84c8124 100644 --- a/src/Integrations/WordPress/Status/PostPublishAbility.php +++ b/src/Integrations/WordPress/Status/PostPublishAbility.php @@ -98,7 +98,13 @@ final class PostPublishAbility implements Ability $data['edit_date'] = true; } - $result = wp_update_post($data, true); + $result = StatusWrite::run( + static fn () => wp_update_post($data, true), + $this->name(), + $this->log, + $post, + 'WordPress hat beim Veröffentlichen einen Fehler gemeldet.' + ); if ($result instanceof WP_Error) { return Errors::invalidInput( 'WordPress hat das Veröffentlichen abgelehnt: ' . $result->get_error_message() diff --git a/src/Integrations/WordPress/Status/PostScheduleAbility.php b/src/Integrations/WordPress/Status/PostScheduleAbility.php index 986a82c..0f1048a 100644 --- a/src/Integrations/WordPress/Status/PostScheduleAbility.php +++ b/src/Integrations/WordPress/Status/PostScheduleAbility.php @@ -108,13 +108,20 @@ final class PostScheduleAbility implements Ability } $postId = (int) $post->ID; - $result = wp_update_post([ + $data = [ 'ID' => $postId, 'post_status' => 'future', 'post_date' => $date->format('Y-m-d H:i:s'), 'post_date_gmt' => $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'), 'edit_date' => true, - ], true); + ]; + $result = StatusWrite::run( + static fn () => wp_update_post($data, true), + $this->name(), + $this->log, + $post, + 'WordPress hat beim Planen einen Fehler gemeldet.' + ); if ($result instanceof WP_Error) { return Errors::invalidInput('WordPress hat das Planen abgelehnt: ' . $result->get_error_message()); } diff --git a/src/Integrations/WordPress/Status/PostTrashAbility.php b/src/Integrations/WordPress/Status/PostTrashAbility.php index 5b0da32..b2aff09 100644 --- a/src/Integrations/WordPress/Status/PostTrashAbility.php +++ b/src/Integrations/WordPress/Status/PostTrashAbility.php @@ -113,7 +113,14 @@ final class PostTrashAbility implements Ability ); } - if (! wp_trash_post($postId)) { + $trashed = StatusWrite::run( + static fn () => wp_trash_post($postId), + $this->name(), + $this->log, + $post, + 'WordPress hat beim Verschieben in den Papierkorb einen Fehler gemeldet.' + ); + if (! $trashed) { return Errors::unsupported( 'WordPress hat das Verschieben in den Papierkorb abgelehnt.', 'Eintrag in WordPress prüfen (ein anderes Plugin kann das Verschieben verhindern).' diff --git a/src/Integrations/WordPress/Status/PostUnpublishAbility.php b/src/Integrations/WordPress/Status/PostUnpublishAbility.php index b16141e..c18307f 100644 --- a/src/Integrations/WordPress/Status/PostUnpublishAbility.php +++ b/src/Integrations/WordPress/Status/PostUnpublishAbility.php @@ -91,7 +91,13 @@ final class PostUnpublishAbility implements Ability ->toArray(); } - $result = wp_update_post(['ID' => $postId, 'post_status' => 'draft'], true); + $result = StatusWrite::run( + static fn () => wp_update_post(['ID' => $postId, 'post_status' => 'draft'], true), + $this->name(), + $this->log, + $post, + 'WordPress hat beim Zurückziehen einen Fehler gemeldet.' + ); if ($result instanceof WP_Error) { return Errors::invalidInput('WordPress hat das Zurückziehen abgelehnt: ' . $result->get_error_message()); } diff --git a/src/Integrations/WordPress/Status/StatusWrite.php b/src/Integrations/WordPress/Status/StatusWrite.php new file mode 100644 index 0000000..4f30e0a --- /dev/null +++ b/src/Integrations/WordPress/Status/StatusWrite.php @@ -0,0 +1,49 @@ +ID; + $log->recordPartial( + $ability, + (string) $post->post_type, + (string) $postId, + sprintf('"%s": Statusänderung abgebrochen, Stand unklar', $post->post_title) + ); + + throw new WriteFailed( + $reason, + false, + $postId, + null, + $e, + '', + sprintf('Status von Eintrag %d ist möglicherweise schon geändert worden.', $postId), + 'Den Aufruf nicht einfach wiederholen. Zuerst mit lit-content-get Status und Inhalt prüfen und dem ' + . 'Nutzer Bescheid geben.' + ); + } + } +} diff --git a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php index ca187ba..f59eeac 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class PostPublishAbilityTest extends TestCase @@ -74,6 +76,26 @@ final class PostPublishAbilityTest extends TestCase self::assertStringContainsString('bereits veröffentlicht', $result['summary']); } + public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void + { + $this->stubPost(['post_status' => 'draft', 'post_date_gmt' => '0000-00-00 00:00:00']); + Functions\when('wp_update_post')->alias(static function (): void { + throw new RuntimeException('save_post-Hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/post-publish', 'page', '5', Mockery::type('string')); + + try { + $this->ability($log)->execute(['post_id' => 5]); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + public function testATrashedPostIsRefused(): void { $this->stubPost(['post_status' => 'trash']); diff --git a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php index 470eb11..d17e550 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php @@ -6,11 +6,13 @@ namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; use DateTimeZone; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class PostScheduleAbilityTest extends TestCase @@ -52,6 +54,25 @@ final class PostScheduleAbilityTest extends TestCase self::assertStringContainsString('2099-06-01 09:30', $result['summary']); } + public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void + { + Functions\when('wp_update_post')->alias(static function (): void { + throw new RuntimeException('save_post-Hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/post-schedule', 'post', '5', Mockery::type('string')); + + try { + $this->ability($log)->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + public function testATrashedPostIsRefused(): void { Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'trash'])); diff --git a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php index ab438b7..4aefab5 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class PostTrashAbilityTest extends TestCase @@ -60,6 +62,25 @@ final class PostTrashAbilityTest extends TestCase self::assertSame('lit_unsupported', $result->get_error_code()); } + public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void + { + Functions\when('wp_trash_post')->alias(static function (): void { + throw new RuntimeException('save_post-Hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/post-trash', 'page', '5', Mockery::type('string')); + + try { + $this->ability($log)->execute(['post_id' => 5, 'confirm' => true]); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + public function testAnAlreadyTrashedPostIsANoOp(): void { $ability = $this->ability(Mockery::mock(ChangeLog::class)); diff --git a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php index 9a0954a..7a23f68 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class PostUnpublishAbilityTest extends TestCase @@ -37,6 +39,25 @@ final class PostUnpublishAbilityTest extends TestCase self::assertSame('unpublished', $result['changed'][0]['action']); } + public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void + { + Functions\when('wp_update_post')->alias(static function (): void { + throw new RuntimeException('save_post-Hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/post-unpublish', 'page', '5', Mockery::type('string')); + + try { + $this->ability($log, 'publish')->execute(['post_id' => 5]); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + public function testATrashedPostIsRefusedInsteadOfBecomingADraft(): void { Functions\expect('wp_update_post')->never();