From 4114bdb805dd343ad7a5cb3055a187f9b08446c1 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:42:33 +0200 Subject: [PATCH] refactor: PostRow::update and PostGuard::assertNoMissedSchedule (M5) - PostRow::update(id, columns, failure) / ::save(): the slashed wp_update_post of internal row writes, refusals as RuntimeException ': '. Used by ContentUpdate::restoreRow, PostCloner::updateRow, MediaMetaWriter apply/restore. - PostGuard::assertNoMissedSchedule(postId, message) replaces the re-read + check in ContentUpdate::restoreRow, PostCloner and DiviSaver::restore (DiviSaver keeps its own kses window; M4). - PostCloner::restore uses Rollback::attempt. Log messages unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Pages/DiviSaver.php | 5 +- src/Integrations/Media/MediaMetaWriter.php | 21 ++------- .../Content/ContentUpdateAbility.php | 15 +++--- src/Integrations/WordPress/PostGuard.php | 16 +++++++ src/Integrations/WordPress/PostRow.php | 46 +++++++++++++++++++ .../WordPress/Status/PostCloner.php | 28 ++++------- .../Integrations/WordPress/PostGuardTest.php | 16 +++++++ .../Integrations/WordPress/PostRowTest.php | 42 +++++++++++++++++ 8 files changed, 140 insertions(+), 49 deletions(-) create mode 100644 src/Integrations/WordPress/PostRow.php create mode 100644 tests/Unit/Integrations/WordPress/PostRowTest.php diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index f20b3a2..9e643cf 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -172,10 +172,7 @@ class DiviSaver { $detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage()); $failures = Rollback::attempt(function () use ($postId, $before): void { - $current = get_post($postId); - if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { - throw new RuntimeException('restoring would publish a missed schedule'); - } + PostGuard::assertNoMissedSchedule($postId, 'restoring would publish a missed schedule'); $restored = $this->withoutKses( static fn (): int|WP_Error diff --git a/src/Integrations/Media/MediaMetaWriter.php b/src/Integrations/Media/MediaMetaWriter.php index 6eaf608..562cb0a 100644 --- a/src/Integrations/Media/MediaMetaWriter.php +++ b/src/Integrations/Media/MediaMetaWriter.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Media; +use LIT\MCP\Integrations\WordPress\PostRow; use RuntimeException; -use WP_Error; use WP_Post; /** @@ -58,15 +58,11 @@ final class MediaMetaWriter if ($columns !== []) { // Set before the call: a throwing save_post hook runs after the row was written. $touched = true; - $result = wp_update_post(wp_slash(array_merge($columns, ['ID' => $id])), true); - if ($result instanceof WP_Error || ! $result) { + $refusal = PostRow::save($id, $columns); + if ($refusal !== null) { // wp_update_post() reports errors before it writes anything. $touched = false; - throw new RuntimeException(sprintf( - 'Attachment %d not updated: %s', - $id, - $result instanceof WP_Error ? $result->get_error_message() : 'unknown error' - )); + throw new RuntimeException(sprintf('Attachment %d not updated: %s', $id, $refusal)); } } @@ -84,14 +80,7 @@ final class MediaMetaWriter { $id = $snapshot['id']; if ($snapshot['columns'] !== []) { - $result = wp_update_post(wp_slash(array_merge($snapshot['columns'], ['ID' => $id])), true); - if ($result instanceof WP_Error || ! $result) { - throw new RuntimeException(sprintf( - 'Attachment %d not restored: %s', - $id, - $result instanceof WP_Error ? $result->get_error_message() : 'unknown error' - )); - } + PostRow::update($id, $snapshot['columns'], sprintf('Attachment %d not restored', $id)); } if (! $snapshot['alt_changed']) { diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index 1178a9a..8201665 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -13,6 +13,7 @@ use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; +use LIT\MCP\Integrations\WordPress\PostRow; use LIT\MCP\Integrations\WordPress\RevisionPoint; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use RuntimeException; @@ -269,10 +270,10 @@ final class ContentUpdateAbility implements Ability } // Both restore paths save through wp_update_post(), which would publish a missed schedule. - $current = get_post((int) $post->ID); - if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { - throw new RuntimeException('Post row not restored: saving it now would publish a missed schedule.'); - } + PostGuard::assertNoMissedSchedule( + (int) $post->ID, + 'Post row not restored: saving it now would publish a missed schedule.' + ); $fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED)); if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) { @@ -286,10 +287,6 @@ final class ContentUpdateAbility implements Ability return; } - $result = wp_update_post(wp_slash(array_merge($before, ['ID' => (int) $post->ID])), true); - if ($result instanceof WP_Error || ! $result) { - $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; - throw new RuntimeException('Post row could not be restored: ' . $message); - } + PostRow::update((int) $post->ID, $before, 'Post row could not be restored'); } } diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index b8d3f71..ace98c1 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress; use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use RuntimeException; use WP_Error; use WP_Post; @@ -83,6 +84,21 @@ final class PostGuard return $time !== false && $time <= time() + self::PUBLISH_WINDOW; } + /** + * For internal writes (rollbacks, clone rows): wp_update_post() would publish a missed + * schedule (see missedSchedule()), so the entry is re-read and such a save is refused. + * $message is the internal log text of the RuntimeException. + * + * @throws RuntimeException + */ + public static function assertNoMissedSchedule(int $postId, string $message): void + { + $post = get_post($postId); + if ($post instanceof WP_Post && self::missedSchedule($post)) { + throw new RuntimeException($message); + } + } + /** * Refuses a write that would change the status as a side effect (spec §2: status is only * changed on explicit request) – currently a missed schedule, which any save would publish. diff --git a/src/Integrations/WordPress/PostRow.php b/src/Integrations/WordPress/PostRow.php new file mode 100644 index 0000000..32808c7 --- /dev/null +++ b/src/Integrations/WordPress/PostRow.php @@ -0,0 +1,46 @@ + $columns Column => value (unslashed). + * @param string $failure Log text; the exception reads ": ". + * @throws RuntimeException When WordPress refuses the save. + */ + public static function update(int $id, array $columns, string $failure): void + { + $refusal = self::save($id, $columns); + if ($refusal !== null) { + throw new RuntimeException($failure . ': ' . $refusal); + } + } + + /** + * The save itself, for callers that must react before throwing. + * + * @param array $columns + * @return string|null Why WordPress refused (WP_Error message or "unknown error"), null when saved. + */ + public static function save(int $id, array $columns): ?string + { + $result = wp_update_post(wp_slash(array_merge($columns, ['ID' => $id])), true); + if ($result instanceof WP_Error) { + return $result->get_error_message(); + } + + return $result ? null : 'unknown error'; + } +} diff --git a/src/Integrations/WordPress/Status/PostCloner.php b/src/Integrations/WordPress/Status/PostCloner.php index 8d6b5cd..c10fc08 100644 --- a/src/Integrations/WordPress/Status/PostCloner.php +++ b/src/Integrations/WordPress/Status/PostCloner.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Status; +use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\WordPress\PostGuard; +use LIT\MCP\Integrations\WordPress\PostRow; use RuntimeException; use Throwable; use WP_Error; @@ -143,14 +145,7 @@ class PostCloner 'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']), 'terms' => fn () => $this->setTerms($postId, $snapshot['terms']), ]; - $failures = []; - foreach (array_intersect_key($steps, array_flip($parts)) as $step) { - try { - $step(); - } catch (Throwable $e) { - $failures[] = $e->getMessage(); - } - } + $failures = Rollback::attempt(...array_values(array_intersect_key($steps, array_flip($parts)))); if ($failures !== []) { throw new RuntimeException('Restore incomplete: ' . implode('; ', $failures)); @@ -225,11 +220,7 @@ class PostCloner private function updateRow(int $postId, array $columns): void { $this->assertNoMissedSchedule($postId); - $result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true); - if ($result instanceof WP_Error || ! $result) { - $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; - throw new RuntimeException(sprintf('Post %d could not be updated: %s', $postId, $message)); - } + PostRow::update($postId, $columns, sprintf('Post %d could not be updated', $postId)); } /** @@ -239,13 +230,10 @@ class PostCloner */ private function assertNoMissedSchedule(int $postId): void { - $post = get_post($postId); - if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) { - throw new RuntimeException(sprintf( - 'Post %d not written: saving it now would publish a missed schedule.', - $postId - )); - } + PostGuard::assertNoMissedSchedule( + $postId, + sprintf('Post %d not written: saving it now would publish a missed schedule.', $postId) + ); } /** diff --git a/tests/Unit/Integrations/WordPress/PostGuardTest.php b/tests/Unit/Integrations/WordPress/PostGuardTest.php index ed06161..b88bbe1 100644 --- a/tests/Unit/Integrations/WordPress/PostGuardTest.php +++ b/tests/Unit/Integrations/WordPress/PostGuardTest.php @@ -148,4 +148,20 @@ final class PostGuardTest extends TestCase Functions\when('current_user_can')->justReturn(false); self::assertSame('lit_forbidden', PostGuard::postType('page', $this->schema(), 'create_posts')->get_error_code()); } + + public function testAssertNoMissedScheduleRefusesAMissedScheduleWithTheCallersMessage(): void + { + Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) { + 5 => new WP_Post(['ID' => 5, 'post_status' => 'future', 'post_date_gmt' => '2020-01-01 09:00:00']), + 6 => new WP_Post(['ID' => 6, 'post_status' => 'publish']), + default => null, + }); + + PostGuard::assertNoMissedSchedule(6, 'nie'); + PostGuard::assertNoMissedSchedule(7, 'nie'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Post 5 not written'); + PostGuard::assertNoMissedSchedule(5, 'Post 5 not written'); + } } diff --git a/tests/Unit/Integrations/WordPress/PostRowTest.php b/tests/Unit/Integrations/WordPress/PostRowTest.php new file mode 100644 index 0000000..580ffbe --- /dev/null +++ b/tests/Unit/Integrations/WordPress/PostRowTest.php @@ -0,0 +1,42 @@ +alias(static fn (array $data): array => $data + ['slashed' => 1]); + Functions\expect('wp_update_post')->once() + ->with(['post_title' => 'A', 'ID' => 5, 'slashed' => 1], true) + ->andReturn(5); + + PostRow::update(5, ['post_title' => 'A'], 'nie'); + } + + public function testUpdateThrowsTheFailureWithWordPressReason(): void + { + Functions\when('wp_update_post')->justReturn(new WP_Error('db', 'Datenbank weg')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Post 5 could not be updated: Datenbank weg'); + PostRow::update(5, ['post_title' => 'A'], 'Post 5 could not be updated'); + } + + public function testSaveReportsAZeroResultAsUnknownError(): void + { + Functions\when('wp_update_post')->justReturn(0); + self::assertSame('unknown error', PostRow::save(5, ['post_title' => 'A'])); + + Functions\when('wp_update_post')->justReturn(5); + self::assertNull(PostRow::save(5, ['post_title' => 'A'])); + } +}