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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 01:23:09 +02:00
co-authored by Claude Opus 5.5
parent e57b53d0e5
commit 563d9e8d15
9 changed files with 165 additions and 5 deletions
@@ -98,7 +98,13 @@ final class PostPublishAbility implements Ability
$data['edit_date'] = true; $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) { if ($result instanceof WP_Error) {
return Errors::invalidInput( return Errors::invalidInput(
'WordPress hat das Veröffentlichen abgelehnt: ' . $result->get_error_message() 'WordPress hat das Veröffentlichen abgelehnt: ' . $result->get_error_message()
@@ -108,13 +108,20 @@ final class PostScheduleAbility implements Ability
} }
$postId = (int) $post->ID; $postId = (int) $post->ID;
$result = wp_update_post([ $data = [
'ID' => $postId, 'ID' => $postId,
'post_status' => 'future', 'post_status' => 'future',
'post_date' => $date->format('Y-m-d H:i:s'), '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'), 'post_date_gmt' => $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'),
'edit_date' => true, '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) { if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Planen abgelehnt: ' . $result->get_error_message()); return Errors::invalidInput('WordPress hat das Planen abgelehnt: ' . $result->get_error_message());
} }
@@ -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( return Errors::unsupported(
'WordPress hat das Verschieben in den Papierkorb abgelehnt.', 'WordPress hat das Verschieben in den Papierkorb abgelehnt.',
'Eintrag in WordPress prüfen (ein anderes Plugin kann das Verschieben verhindern).' 'Eintrag in WordPress prüfen (ein anderes Plugin kann das Verschieben verhindern).'
@@ -91,7 +91,13 @@ final class PostUnpublishAbility implements Ability
->toArray(); ->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) { if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Zurückziehen abgelehnt: ' . $result->get_error_message()); return Errors::invalidInput('WordPress hat das Zurückziehen abgelehnt: ' . $result->get_error_message());
} }
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use Throwable;
use WP_Post;
/**
* Runs the one WordPress call of a status tool (wp_update_post(), wp_trash_post()). If a
* hook throws inside it, the row may already be written – the entry's state is unknown,
* so this is a partial write (logged as such), never a neutral internal error.
*/
final class StatusWrite
{
/**
* @param callable(): mixed $write
* @throws WriteFailed When $write throws.
*/
public static function run(callable $write, string $ability, ChangeLog $log, WP_Post $post, string $reason): mixed
{
try {
return $write();
} catch (Throwable $e) {
$postId = (int) $post->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.'
);
}
}
}
@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility; use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
use RuntimeException;
use WP_Post; use WP_Post;
final class PostPublishAbilityTest extends TestCase final class PostPublishAbilityTest extends TestCase
@@ -74,6 +76,26 @@ final class PostPublishAbilityTest extends TestCase
self::assertStringContainsString('bereits veröffentlicht', $result['summary']); 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 public function testATrashedPostIsRefused(): void
{ {
$this->stubPost(['post_status' => 'trash']); $this->stubPost(['post_status' => 'trash']);
@@ -6,11 +6,13 @@ namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use DateTimeZone; use DateTimeZone;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility; use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
use RuntimeException;
use WP_Post; use WP_Post;
final class PostScheduleAbilityTest extends TestCase final class PostScheduleAbilityTest extends TestCase
@@ -52,6 +54,25 @@ final class PostScheduleAbilityTest extends TestCase
self::assertStringContainsString('2099-06-01 09:30', $result['summary']); 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 public function testATrashedPostIsRefused(): void
{ {
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'trash'])); Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'trash']));
@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility; use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
use RuntimeException;
use WP_Post; use WP_Post;
final class PostTrashAbilityTest extends TestCase final class PostTrashAbilityTest extends TestCase
@@ -60,6 +62,25 @@ final class PostTrashAbilityTest extends TestCase
self::assertSame('lit_unsupported', $result->get_error_code()); 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 public function testAnAlreadyTrashedPostIsANoOp(): void
{ {
$ability = $this->ability(Mockery::mock(ChangeLog::class)); $ability = $this->ability(Mockery::mock(ChangeLog::class));
@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
use RuntimeException;
use WP_Post; use WP_Post;
final class PostUnpublishAbilityTest extends TestCase final class PostUnpublishAbilityTest extends TestCase
@@ -37,6 +39,25 @@ final class PostUnpublishAbilityTest extends TestCase
self::assertSame('unpublished', $result['changed'][0]['action']); 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 public function testATrashedPostIsRefusedInsteadOfBecomingADraft(): void
{ {
Functions\expect('wp_update_post')->never(); Functions\expect('wp_update_post')->never();