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 '<failure>: <why>'. 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) <noreply@anthropic.com>
This commit is contained in:
@@ -172,10 +172,7 @@ class DiviSaver
|
|||||||
{
|
{
|
||||||
$detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage());
|
$detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage());
|
||||||
$failures = Rollback::attempt(function () use ($postId, $before): void {
|
$failures = Rollback::attempt(function () use ($postId, $before): void {
|
||||||
$current = get_post($postId);
|
PostGuard::assertNoMissedSchedule($postId, 'restoring would publish a missed schedule');
|
||||||
if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) {
|
|
||||||
throw new RuntimeException('restoring would publish a missed schedule');
|
|
||||||
}
|
|
||||||
|
|
||||||
$restored = $this->withoutKses(
|
$restored = $this->withoutKses(
|
||||||
static fn (): int|WP_Error
|
static fn (): int|WP_Error
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Media;
|
namespace LIT\MCP\Integrations\Media;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostRow;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use WP_Error;
|
|
||||||
use WP_Post;
|
use WP_Post;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,15 +58,11 @@ final class MediaMetaWriter
|
|||||||
if ($columns !== []) {
|
if ($columns !== []) {
|
||||||
// Set before the call: a throwing save_post hook runs after the row was written.
|
// Set before the call: a throwing save_post hook runs after the row was written.
|
||||||
$touched = true;
|
$touched = true;
|
||||||
$result = wp_update_post(wp_slash(array_merge($columns, ['ID' => $id])), true);
|
$refusal = PostRow::save($id, $columns);
|
||||||
if ($result instanceof WP_Error || ! $result) {
|
if ($refusal !== null) {
|
||||||
// wp_update_post() reports errors before it writes anything.
|
// wp_update_post() reports errors before it writes anything.
|
||||||
$touched = false;
|
$touched = false;
|
||||||
throw new RuntimeException(sprintf(
|
throw new RuntimeException(sprintf('Attachment %d not updated: %s', $id, $refusal));
|
||||||
'Attachment %d not updated: %s',
|
|
||||||
$id,
|
|
||||||
$result instanceof WP_Error ? $result->get_error_message() : 'unknown error'
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +80,7 @@ final class MediaMetaWriter
|
|||||||
{
|
{
|
||||||
$id = $snapshot['id'];
|
$id = $snapshot['id'];
|
||||||
if ($snapshot['columns'] !== []) {
|
if ($snapshot['columns'] !== []) {
|
||||||
$result = wp_update_post(wp_slash(array_merge($snapshot['columns'], ['ID' => $id])), true);
|
PostRow::update($id, $snapshot['columns'], sprintf('Attachment %d not restored', $id));
|
||||||
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'
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $snapshot['alt_changed']) {
|
if (! $snapshot['alt_changed']) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use LIT\MCP\Abilities\WriteFailed;
|
|||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\Integrations\WordPress\PostChangeResult;
|
use LIT\MCP\Integrations\WordPress\PostChangeResult;
|
||||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostRow;
|
||||||
use LIT\MCP\Integrations\WordPress\RevisionPoint;
|
use LIT\MCP\Integrations\WordPress\RevisionPoint;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use RuntimeException;
|
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.
|
// Both restore paths save through wp_update_post(), which would publish a missed schedule.
|
||||||
$current = get_post((int) $post->ID);
|
PostGuard::assertNoMissedSchedule(
|
||||||
if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) {
|
(int) $post->ID,
|
||||||
throw new RuntimeException('Post row not restored: saving it now would publish a missed schedule.');
|
'Post row not restored: saving it now would publish a missed schedule.'
|
||||||
}
|
);
|
||||||
|
|
||||||
$fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED));
|
$fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED));
|
||||||
if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) {
|
if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) {
|
||||||
@@ -286,10 +287,6 @@ final class ContentUpdateAbility implements Ability
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = wp_update_post(wp_slash(array_merge($before, ['ID' => (int) $post->ID])), true);
|
PostRow::update((int) $post->ID, $before, 'Post row could not be restored');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress;
|
|||||||
|
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
|
use RuntimeException;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
use WP_Post;
|
use WP_Post;
|
||||||
|
|
||||||
@@ -83,6 +84,21 @@ final class PostGuard
|
|||||||
return $time !== false && $time <= time() + self::PUBLISH_WINDOW;
|
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
|
* 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.
|
* changed on explicit request) – currently a missed schedule, which any save would publish.
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal writes of post-row columns through wp_update_post() (slashed, WP_Error mode):
|
||||||
|
* rollbacks, clone rows, media metadata. Refusals become RuntimeExceptions with an internal
|
||||||
|
* message for the PHP error log. Callers that could hit a scheduled entry check
|
||||||
|
* PostGuard::assertNoMissedSchedule() first.
|
||||||
|
*/
|
||||||
|
final class PostRow
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $columns Column => value (unslashed).
|
||||||
|
* @param string $failure Log text; the exception reads "<failure>: <why>".
|
||||||
|
* @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<string, mixed> $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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Rollback;
|
||||||
use LIT\MCP\Abilities\WriteFailed;
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostRow;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
@@ -143,14 +145,7 @@ class PostCloner
|
|||||||
'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']),
|
'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']),
|
||||||
'terms' => fn () => $this->setTerms($postId, $snapshot['terms']),
|
'terms' => fn () => $this->setTerms($postId, $snapshot['terms']),
|
||||||
];
|
];
|
||||||
$failures = [];
|
$failures = Rollback::attempt(...array_values(array_intersect_key($steps, array_flip($parts))));
|
||||||
foreach (array_intersect_key($steps, array_flip($parts)) as $step) {
|
|
||||||
try {
|
|
||||||
$step();
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$failures[] = $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($failures !== []) {
|
if ($failures !== []) {
|
||||||
throw new RuntimeException('Restore incomplete: ' . implode('; ', $failures));
|
throw new RuntimeException('Restore incomplete: ' . implode('; ', $failures));
|
||||||
@@ -225,11 +220,7 @@ class PostCloner
|
|||||||
private function updateRow(int $postId, array $columns): void
|
private function updateRow(int $postId, array $columns): void
|
||||||
{
|
{
|
||||||
$this->assertNoMissedSchedule($postId);
|
$this->assertNoMissedSchedule($postId);
|
||||||
$result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true);
|
PostRow::update($postId, $columns, sprintf('Post %d could not be updated', $postId));
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,13 +230,10 @@ class PostCloner
|
|||||||
*/
|
*/
|
||||||
private function assertNoMissedSchedule(int $postId): void
|
private function assertNoMissedSchedule(int $postId): void
|
||||||
{
|
{
|
||||||
$post = get_post($postId);
|
PostGuard::assertNoMissedSchedule(
|
||||||
if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) {
|
$postId,
|
||||||
throw new RuntimeException(sprintf(
|
sprintf('Post %d not written: saving it now would publish a missed schedule.', $postId)
|
||||||
'Post %d not written: saving it now would publish a missed schedule.',
|
);
|
||||||
$postId
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -148,4 +148,20 @@ final class PostGuardTest extends TestCase
|
|||||||
Functions\when('current_user_can')->justReturn(false);
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
self::assertSame('lit_forbidden', PostGuard::postType('page', $this->schema(), 'create_posts')->get_error_code());
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostRow;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
final class PostRowTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testUpdateSavesTheSlashedColumnsWithTheId(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_slash')->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']));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user