diff --git a/src/Integrations/WordPress/Status/PostCloner.php b/src/Integrations/WordPress/Status/PostCloner.php index 3b350a7..6c62d7d 100644 --- a/src/Integrations/WordPress/Status/PostCloner.php +++ b/src/Integrations/WordPress/Status/PostCloner.php @@ -11,10 +11,12 @@ use WP_Error; use WP_Post; /** - * Copies entries (draft copy) and swaps a copy's content back into its original. - * Meta is copied raw – Divi flags (_et_pb_*), page template (_wp_page_template), - * featured image (_thumbnail_id) and Pods meta included; Pods relationships stored in - * the meta table travel with it. Only per-post bookkeeping (SKIP_META) stays put. + * Copies entries (draft copy, trashed backup) and swaps a copy's content back into its + * original. Meta is copied byte-exact: the stored meta_value strings are read and + * inserted directly via $wpdb, so nothing is unserialized, re-serialized or unslashed on + * the way – Divi flags (_et_pb_*), page template (_wp_page_template), featured image + * (_thumbnail_id) and Pods meta included; Pods relationships stored in the meta table + * travel with it. Only per-post bookkeeping (SKIP_META) stays put. * snapshot()/restore() let lit/post-replace-with-copy undo a failed swap. * Not final so abilities can be tested with a mock. */ @@ -22,15 +24,25 @@ class PostCloner { public const COPY_OF_META = '_lit_mcp_copy_of'; + public const BACKUP_OF_META = '_lit_mcp_backup_of'; + public const COPY_SUFFIX = ' (Kopie)'; + public const BACKUP_SUFFIX = ' (Sicherung)'; + + /** Parts of a post replace()/restore() write. */ + public const PARTS = ['post', 'meta', 'terms']; + /** Bookkeeping meta that belongs to one post and must never be copied or cleared. */ private const SKIP_META = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date', '_wp_trash_meta_status', '_wp_trash_meta_time', '_wp_desired_post_slug', '_encloseme', '_pingme', self::COPY_OF_META, + self::BACKUP_OF_META, ]; - private const DUPLICATE_REASON = 'Die Kopie konnte nicht vollständig angelegt werden.'; + public function __construct(private ?\wpdb $db = null) + { + } /** * Creates " (Kopie)" as draft of the current user. All or nothing: if meta or @@ -40,75 +52,63 @@ class PostCloner */ public function duplicate(WP_Post $original): int { - $originalId = (int) $original->ID; - $copyId = wp_insert_post(wp_slash([ - 'post_type' => (string) $original->post_type, - 'post_status' => 'draft', - 'post_title' => (string) $original->post_title . self::COPY_SUFFIX, - 'post_content' => (string) $original->post_content, - 'post_excerpt' => (string) $original->post_excerpt, - 'post_parent' => (int) $original->post_parent, - 'menu_order' => (int) $original->menu_order, - 'comment_status' => (string) $original->comment_status, - 'ping_status' => (string) $original->ping_status, - 'post_password' => (string) $original->post_password, - 'post_author' => get_current_user_id(), - ]), true); + return $this->cloneAsDraft($original, self::COPY_SUFFIX, self::COPY_OF_META, 'Die Kopie'); + } - if ($copyId instanceof WP_Error || (int) $copyId === 0) { - $reason = $copyId instanceof WP_Error ? $copyId->get_error_message() : 'unknown error'; - throw new WriteFailed( - 'Die Kopie konnte nicht angelegt werden.', - true, - null, - null, - null, - sprintf('Copy of post %d could not be created: %s', $originalId, $reason) - ); - } + /** + * Creates "<title> (Sicherung)" – a full clone of the post – and moves it to the trash. + * If either step fails, the backup is deleted again. Call only when the trash is enabled. + * + * @throws WriteFailed + */ + public function backup(WP_Post $original): int + { + $backupId = $this->cloneAsDraft($original, self::BACKUP_SUFFIX, self::BACKUP_OF_META, 'Die Sicherung'); - $copyId = (int) $copyId; try { - $this->replaceMeta($copyId, $this->ownMeta($originalId)); - $this->setTerms($copyId, $this->termIds($original)); - if (update_post_meta($copyId, self::COPY_OF_META, $originalId) === false) { - throw new RuntimeException('Copy marker could not be written.'); + $trashed = wp_trash_post($backupId); + if (! $trashed || get_post_status($backupId) !== 'trash') { + throw new RuntimeException('Backup could not be moved to the trash.'); } } catch (Throwable $e) { - $deleted = wp_delete_post($copyId, true); - $detail = sprintf('Copy %d of post %d failed: %s', $copyId, $originalId, $e->getMessage()); - if ($deleted) { - throw new WriteFailed(self::DUPLICATE_REASON, true, null, null, $e, $detail . '; draft deleted'); - } - - throw new WriteFailed(self::DUPLICATE_REASON, false, $copyId, null, $e, $detail); + $this->discard($original, $backupId, $e, 'Die Sicherung konnte nicht in den Papierkorb gelegt werden.'); } - return $copyId; + return $backupId; } /** * Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy * into the original. Status, slug, date and author of the original stay. * - * @throws RuntimeException + * @throws ReplaceFailed Naming the parts that were (possibly) written. */ public function replace(WP_Post $original, WP_Post $copy): void { $originalId = (int) $original->ID; - $this->updateRow($originalId, [ - 'post_title' => self::withoutSuffix((string) $copy->post_title), - 'post_content' => (string) $copy->post_content, - 'post_excerpt' => (string) $copy->post_excerpt, - ]); - $this->replaceMeta($originalId, $this->ownMeta((int) $copy->ID)); - $this->setTerms($originalId, $this->termIds($copy)); + $parts = []; + try { + $parts[] = 'post'; + $this->updateRow($originalId, [ + 'post_title' => self::withoutSuffix((string) $copy->post_title), + 'post_content' => (string) $copy->post_content, + 'post_excerpt' => (string) $copy->post_excerpt, + ]); + $meta = $this->ownMeta((int) $copy->ID); + $terms = $this->termIds($copy); + $parts[] = 'meta'; + $this->replaceMeta($originalId, $meta); + $parts[] = 'terms'; + $this->setTerms($originalId, $terms); + } catch (Throwable $e) { + throw new ReplaceFailed($parts, $e); + } } /** * Everything replace() overwrites – call before replace(). * - * @return array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>} + * @return array{post: array<string, string>, meta: array<string, list<?string>>, terms: array<string, list<int>>} * @throws RuntimeException */ public function snapshot(WP_Post $post): array @@ -125,22 +125,23 @@ class PostCloner } /** - * Writes a snapshot back. Tries every part even if one fails. + * Writes the given parts of a snapshot back. Tries every part even if one fails. * - * @param array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>} + * @param array{post: array<string, string>, meta: array<string, list<?string>>, terms: array<string, list<int>>} * $snapshot Output of snapshot(). + * @param list<string> $parts Subset of PARTS. * @throws RuntimeException Listing every part that could not be restored. */ - public function restore(WP_Post $post, array $snapshot): void + public function restore(WP_Post $post, array $snapshot, array $parts = self::PARTS): void { $postId = (int) $post->ID; $steps = [ - fn () => $this->updateRow($postId, $snapshot['post']), - fn () => $this->replaceMeta($postId, $snapshot['meta']), - fn () => $this->setTerms($postId, $snapshot['terms']), + 'post' => fn () => $this->updateRow($postId, $snapshot['post']), + 'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']), + 'terms' => fn () => $this->setTerms($postId, $snapshot['terms']), ]; $failures = []; - foreach ($steps as $step) { + foreach (array_intersect_key($steps, array_flip($parts)) as $step) { try { $step(); } catch (Throwable $e) { @@ -158,6 +159,66 @@ class PostCloner return (string) preg_replace('/ \(Kopie\)$/u', '', $title); } + /** @throws WriteFailed */ + private function cloneAsDraft(WP_Post $original, string $suffix, string $markerKey, string $label): int + { + $originalId = (int) $original->ID; + $cloneId = wp_insert_post(wp_slash([ + 'post_type' => (string) $original->post_type, + 'post_status' => 'draft', + 'post_title' => (string) $original->post_title . $suffix, + 'post_content' => (string) $original->post_content, + 'post_excerpt' => (string) $original->post_excerpt, + 'post_parent' => (int) $original->post_parent, + 'menu_order' => (int) $original->menu_order, + 'comment_status' => (string) $original->comment_status, + 'ping_status' => (string) $original->ping_status, + 'post_password' => (string) $original->post_password, + 'post_author' => get_current_user_id(), + ]), true); + + if ($cloneId instanceof WP_Error || (int) $cloneId === 0) { + $reason = $cloneId instanceof WP_Error ? $cloneId->get_error_message() : 'unknown error'; + throw new WriteFailed( + $label . ' konnte nicht angelegt werden.', + true, + null, + null, + null, + sprintf('Clone (%s) of post %d could not be created: %s', $markerKey, $originalId, $reason) + ); + } + + $cloneId = (int) $cloneId; + try { + $this->replaceMeta($cloneId, $this->ownMeta($originalId)); + $this->setTerms($cloneId, $this->termIds($original)); + if (update_post_meta($cloneId, $markerKey, $originalId) === false) { + throw new RuntimeException('Clone marker could not be written.'); + } + } catch (Throwable $e) { + $this->discard($original, $cloneId, $e, $label . ' konnte nicht vollständig angelegt werden.'); + } + + return $cloneId; + } + + /** + * Deletes a clone this class just created (never anything else) and reports the failure. + * + * @throws WriteFailed Always. + */ + private function discard(WP_Post $original, int $cloneId, Throwable $cause, string $reason): never + { + $deleted = wp_delete_post($cloneId, true); + $detail = sprintf('Clone %d of post %d failed: %s', $cloneId, (int) $original->ID, $cause->getMessage()); + if ($deleted) { + throw new WriteFailed($reason, true, null, null, $cause, $detail . '; clone deleted'); + } + + throw new WriteFailed($reason, false, $cloneId, null, $cause, $detail . '; clone could not be deleted'); + } + /** * @param array<string, string> $columns * @throws RuntimeException @@ -172,16 +233,26 @@ class PostCloner } /** - * Raw meta of a post without SKIP_META (values as stored, serialized ones as strings). + * Stored meta of a post without SKIP_META: meta_value strings exactly as in the database. * - * @return array<string, list<mixed>> + * @return array<string, list<string|null>> */ private function ownMeta(int $postId): array { + $db = $this->db(); + $rows = $db->get_results( + $db->prepare( + "SELECT meta_key, meta_value FROM {$db->postmeta} WHERE post_id = %d ORDER BY meta_id", + $postId + ), + 'ARRAY_A' + ); + $meta = []; - foreach ((array) get_post_meta($postId) as $key => $values) { - if (! in_array((string) $key, self::SKIP_META, true)) { - $meta[(string) $key] = array_values((array) $values); + foreach ((array) $rows as $row) { + $key = (string) $row['meta_key']; + if (! in_array($key, self::SKIP_META, true)) { + $meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value']; } } @@ -189,25 +260,37 @@ class PostCloner } /** - * Makes the post's meta (except SKIP_META) exactly $meta. + * Makes the post's meta (except SKIP_META) exactly $meta; values are inserted as stored. * - * @param array<string, list<mixed>> $meta + * @param array<string, list<string|null>> $meta * @throws RuntimeException */ private function replaceMeta(int $postId, array $meta): void { - foreach (array_keys($this->ownMeta($postId)) as $key) { - if (delete_post_meta($postId, $key) === false) { - throw new RuntimeException(sprintf('Meta "%s" of post %d could not be deleted.', $key, $postId)); - } - } - - foreach ($meta as $key => $values) { - foreach ($values as $value) { - if (add_post_meta($postId, $key, wp_slash(maybe_unserialize($value))) === false) { - throw new RuntimeException(sprintf('Meta "%s" of post %d could not be written.', $key, $postId)); + $db = $this->db(); + try { + foreach (array_keys($this->ownMeta($postId)) as $key) { + if (delete_post_meta($postId, $key) === false) { + throw new RuntimeException(sprintf('Meta "%s" of post %d could not be deleted.', $key, $postId)); } } + + foreach ($meta as $key => $values) { + foreach ($values as $value) { + $inserted = $db->insert( + $db->postmeta, + ['post_id' => $postId, 'meta_key' => $key, 'meta_value' => $value], + ['%d', '%s', '%s'] + ); + if ($inserted === false) { + throw new RuntimeException( + sprintf('Meta "%s" of post %d could not be written.', $key, $postId) + ); + } + } + } + } finally { + wp_cache_delete($postId, 'post_meta'); } } @@ -246,4 +329,14 @@ class PostCloner } } } + + private function db(): \wpdb + { + if ($this->db === null) { + global $wpdb; + $this->db = $wpdb; + } + + return $this->db; + } } diff --git a/src/Integrations/WordPress/Status/PostDuplicateAbility.php b/src/Integrations/WordPress/Status/PostDuplicateAbility.php index 704cadf..b0988b5 100644 --- a/src/Integrations/WordPress/Status/PostDuplicateAbility.php +++ b/src/Integrations/WordPress/Status/PostDuplicateAbility.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\Ability; +use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; @@ -125,6 +126,14 @@ final class PostDuplicateAbility implements Ability return $post; } + if ($post->post_status === 'trash') { + return Errors::invalidInput( + 'Der Eintrag liegt im Papierkorb.', + ['post_id' => 'Eintrag liegt im Papierkorb'], + 'Eintrag zuerst in WordPress wiederherstellen.' + ); + } + $type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts'); return $type instanceof WP_Error ? $type : $post; diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 99bd823..7f2c403 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -23,10 +23,12 @@ use WP_Post; * (spec §6.2). ID, URL, status and menu items of the original stay. rev of the original * (from lit/content-get) is required so parallel edits are never overwritten. All or * nothing (spec §8.2): the original's title/content/excerpt, meta and terms are - * snapshotted and restored if the swap fails (WriteFailed → lit_write_failed, or - * lit_partial_write when the restore was incomplete). A revision is taken first. The - * copy then goes to the trash – refused up front when the trash is disabled, because - * wp_trash_post() would delete permanently then. + * snapshotted and the parts reached are restored if the swap fails (WriteFailed → + * lit_write_failed, or lit_partial_write when the restore was incomplete). Before the + * swap, a full clone of the original is put into the trash as backup (ruling P12; meta + * and terms are not revisioned) and a revision is taken. The copy then goes to the + * trash as well – refused up front when the trash is disabled, because wp_trash_post() + * would delete permanently then. */ final class PostReplaceWithCopyAbility implements Ability { @@ -59,8 +61,11 @@ final class PostReplaceWithCopyAbility implements Ability { return 'Übernimmt Titel, Inhalt, Felder/Meta und Begriffe einer mit lit-post-duplicate erstellten Kopie ' . 'ins Original. ID, URL, Status und Menüeinträge des Originals bleiben; vorher entsteht eine ' - . 'Revision des Originals, die Kopie kommt danach in den Papierkorb. rev des Originals aus ' - . 'lit-content-get ist Pflicht. Erst nach Rückfrage mit confirm: true aufrufen.'; + . 'Revision und eine vollständige Sicherung des Originals (im Papierkorb, backup_id), die Kopie kommt ' + . 'danach in den Papierkorb. Die Kopie muss Entwurf oder ausstehend sein. rev des Originals aus ' + . 'lit-content-get ist Pflicht; sie deckt nur Titel, Inhalt, Auszug und Status ab – Felder/Meta, die ' + . 'nach dem Duplizieren direkt am Original geändert wurden, werden überschrieben (stehen aber in der ' + . 'Sicherung). Erst nach Rückfrage mit confirm: true aufrufen.'; } public function inputSchema(): array @@ -83,7 +88,7 @@ final class PostReplaceWithCopyAbility implements Ability public function outputSchema(): ?array { - return ToolResult::schema(); + return ToolResult::schema(['backup_id' => ['type' => 'integer']], ['backup_id']); } public function annotations(): array @@ -123,14 +128,18 @@ final class PostReplaceWithCopyAbility implements Ability throw new WriteFailed(self::REASON, true, $originalId, null, $e); } + // Throws WriteFailed (backup deleted again) before anything touches the original. + $backupId = $this->cloner->backup($original); $revisionId = RevisionPoint::capture($originalId); try { $this->cloner->replace($original, $copy); + } catch (ReplaceFailed $e) { + $this->rollBack($original, $snapshot, $e->parts, $revisionId, $e->getPrevious() ?? $e); } catch (Throwable $e) { - $this->rollBack($original, $snapshot, $revisionId, $e); + $this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $e); } - return $this->result($original, $copy, $input, $revisionId); + return $this->result($original, $copy, $input, $revisionId, $backupId); } /** @@ -160,11 +169,19 @@ final class PostReplaceWithCopyAbility implements Ability ); } - if ($copy->post_status === 'trash') { + if ($original->post_status === 'trash') { return Errors::invalidInput( - 'Die Kopie liegt im Papierkorb.', - ['copy_id' => 'Kopie liegt im Papierkorb'], - 'Kopie in WordPress wiederherstellen oder mit lit-post-duplicate eine neue Kopie erstellen.' + 'Das Original liegt im Papierkorb.', + ['original_id' => 'Original liegt im Papierkorb'], + 'Original zuerst in WordPress wiederherstellen.' + ); + } + + if (! in_array($copy->post_status, ['draft', 'pending'], true)) { + return Errors::invalidInput( + sprintf('Die Kopie hat den Status "%s"; übernommen wird nur ein Entwurf.', $copy->post_status), + ['copy_id' => 'Kopie muss Entwurf oder ausstehend sein'], + 'Mit lit-post-duplicate eine neue Kopie erstellen (bzw. die Kopie in WordPress wiederherstellen).' ); } @@ -187,7 +204,7 @@ final class PostReplaceWithCopyAbility implements Ability * @param array<string, mixed> $input * @return array<string, mixed> */ - private function result(WP_Post $original, WP_Post $copy, array $input, ?int $revisionId): array + private function result(WP_Post $original, WP_Post $copy, array $input, ?int $revisionId, int $backupId): array { $originalId = (int) $original->ID; $copyId = (int) $copy->ID; @@ -226,9 +243,10 @@ final class PostReplaceWithCopyAbility implements Ability $updated = get_post($originalId); $updated = $updated instanceof WP_Post ? $updated : $original; $statusNote = sprintf( - '; Status bleibt %s, %s.', + '; Status bleibt %s, %s, der vorherige Stand liegt als Sicherung ID %d im Papierkorb.', $original->post_status, - $trashed ? 'Kopie liegt im Papierkorb' : 'Kopie besteht weiter' + $trashed ? 'Kopie liegt im Papierkorb' : 'Kopie besteht weiter', + $backupId ); $result = ToolResult::ok($summary . $statusNote) @@ -243,20 +261,27 @@ final class PostReplaceWithCopyAbility implements Ability ->logId(is_int($logId) ? $logId : null) ->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw')) ->warnings($warnings) + ->with('backup_id', $backupId) ->toArray(); } /** * @param array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>} * $snapshot Output of PostCloner::snapshot(). + * @param list<string> $parts Parts replace() had (possibly) written. * @throws WriteFailed Always – rolledBack tells whether the original was restored completely. */ - private function rollBack(WP_Post $original, array $snapshot, ?int $revisionId, Throwable $cause): never - { + private function rollBack( + WP_Post $original, + array $snapshot, + array $parts, + ?int $revisionId, + Throwable $cause + ): never { $originalId = (int) $original->ID; $detail = sprintf('Replace of post %d failed: %s', $originalId, $cause->getMessage()); try { - $this->cloner->restore($original, $snapshot); + $this->cloner->restore($original, $snapshot, $parts); } catch (Throwable $e) { $ignored = []; FollowUp::run( diff --git a/src/Integrations/WordPress/Status/ReplaceFailed.php b/src/Integrations/WordPress/Status/ReplaceFailed.php new file mode 100644 index 0000000..0a3a056 --- /dev/null +++ b/src/Integrations/WordPress/Status/ReplaceFailed.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace LIT\MCP\Integrations\WordPress\Status; + +use RuntimeException; +use Throwable; + +/** + * Thrown by PostCloner::replace(). $parts lists the parts of the original replace() + * had started to write (post, meta, terms) – only those need to be restored. + */ +final class ReplaceFailed extends RuntimeException +{ + /** @param list<string> $parts */ + public function __construct(public readonly array $parts, Throwable $previous) + { + parent::__construct($previous->getMessage(), 0, $previous); + } +} diff --git a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php index 1899100..491ce4a 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php @@ -7,13 +7,54 @@ namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\WordPress\Status\PostCloner; +use LIT\MCP\Integrations\WordPress\Status\ReplaceFailed; use LIT\MCP\Tests\Unit\TestCase; +use Mockery; use RuntimeException; use WP_Error; use WP_Post; final class PostClonerTest extends TestCase { + /** @var array<int, array<string, list<string|null>>> Stored meta by post ID, as meta_value strings. */ + private array $meta = []; + + /** @var list<array{0: int, 1: string, 2: string|null}> Raw postmeta inserts. */ + private array $inserts = []; + + private bool $insertFails = false; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('wp_cache_delete')->justReturn(true); + } + + private function cloner(): PostCloner + { + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->allows('get_results')->andReturnUsing(function (string $sql): array { + preg_match('/post_id = (\d+)/', $sql, $match); + $rows = []; + foreach ($this->meta[(int) $match[1]] ?? [] as $key => $values) { + foreach ($values as $value) { + $rows[] = ['meta_key' => $key, 'meta_value' => $value]; + } + } + + return $rows; + }); + $db->allows('insert')->andReturnUsing(function (string $table, array $data, array $format) { + self::assertSame('wp_postmeta', $table); + self::assertSame(['%d', '%s', '%s'], $format); + $this->inserts[] = [$data['post_id'], $data['meta_key'], $data['meta_value']]; + + return $this->insertFails ? false : 1; + }); + + return new PostCloner($db); + } + private function original(): WP_Post { return new WP_Post([ @@ -41,33 +82,64 @@ final class PostClonerTest extends TestCase public function testDuplicateCopiesContentMetaAndTermsAsDraft(): void { Functions\when('get_current_user_id')->justReturn(4); - Functions\when('maybe_unserialize')->returnArg(); - Functions\expect('wp_insert_post')->once()->with($this->draftMatcher(), true)->andReturn(77); - Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5 - ? [ + Functions\expect('wp_insert_post')->once()->with($this->draftMatcher('Team (Kopie)'), true)->andReturn(77); + $this->meta = [ + 5 => [ '_edit_lock' => ['123:1'], '_edit_last' => ['1'], '_wp_old_slug' => ['team-alt'], + '_lit_mcp_backup_of' => ['3'], '_et_pb_use_builder' => ['on'], '_et_pb_use_divi_5' => ['on'], '_wp_page_template' => ['page-template-blank.php'], '_thumbnail_id' => ['31'], 'role' => ['Leitung'], - ] - : ['_edit_lock' => ['999:4'], '_et_pb_use_builder' => ['off']]); - // Meta a save hook put on the fresh copy is replaced by the original's. + ], + // Meta a save hook put on the fresh copy is replaced by the original's. + 77 => ['_edit_lock' => ['999:4'], '_et_pb_use_builder' => ['off']], + ]; Functions\expect('delete_post_meta')->once()->with(77, '_et_pb_use_builder'); - Functions\expect('add_post_meta')->once()->with(77, '_et_pb_use_builder', 'on'); - Functions\expect('add_post_meta')->once()->with(77, '_et_pb_use_divi_5', 'on'); - Functions\expect('add_post_meta')->once()->with(77, '_wp_page_template', 'page-template-blank.php'); - Functions\expect('add_post_meta')->once()->with(77, '_thumbnail_id', '31'); - Functions\expect('add_post_meta')->once()->with(77, 'role', 'Leitung'); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([4, 9]); Functions\expect('wp_set_object_terms')->once()->with(77, [4, 9], 'category', false); Functions\expect('update_post_meta')->once()->with(77, '_lit_mcp_copy_of', 5); - self::assertSame(77, (new PostCloner())->duplicate($this->original())); + self::assertSame(77, $this->cloner()->duplicate($this->original())); + self::assertSame([ + [77, '_et_pb_use_builder', 'on'], + [77, '_et_pb_use_divi_5', 'on'], + [77, '_wp_page_template', 'page-template-blank.php'], + [77, '_thumbnail_id', '31'], + [77, 'role', 'Leitung'], + ], $this->inserts); + } + + public function testMetaIsCopiedByteExact(): void + { + Functions\when('get_current_user_id')->justReturn(4); + Functions\when('wp_insert_post')->justReturn(77); + Functions\when('get_object_taxonomies')->justReturn([]); + Functions\when('update_post_meta')->justReturn(true); + $raw = [ + 'path' => ['a:1:{s:1:"p";s:9:"C:\\new\\x\\";}'], + 'quoted' => ['Er sagte \\"hallo\\"'], + 'flag' => ['b:0;'], + 'object' => ['O:8:"stdClass":1:{s:1:"a";i:1;}'], + 'empty' => [''], + 'null' => [null], + ]; + $this->meta = [5 => $raw]; + + $this->cloner()->duplicate($this->original()); + + self::assertSame([ + [77, 'path', 'a:1:{s:1:"p";s:9:"C:\\new\\x\\";}'], + [77, 'quoted', 'Er sagte \\"hallo\\"'], + [77, 'flag', 'b:0;'], + [77, 'object', 'O:8:"stdClass":1:{s:1:"a";i:1;}'], + [77, 'empty', ''], + [77, 'null', null], + ], $this->inserts); } public function testDuplicateFailureThrows(): void @@ -77,20 +149,19 @@ final class PostClonerTest extends TestCase $this->expectException(RuntimeException::class); - (new PostCloner())->duplicate($this->original()); + $this->cloner()->duplicate($this->original()); } public function testDuplicateFailingAfterInsertDeletesTheNewDraft(): void { Functions\when('get_current_user_id')->justReturn(4); - Functions\when('maybe_unserialize')->returnArg(); Functions\when('wp_insert_post')->justReturn(77); - Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5 ? ['role' => ['Leitung']] : []); - Functions\when('add_post_meta')->justReturn(false); + $this->meta = [5 => ['role' => ['Leitung']]]; + $this->insertFails = true; Functions\expect('wp_delete_post')->once()->with(77, true)->andReturn(new WP_Post(['ID' => 77])); try { - (new PostCloner())->duplicate($this->original()); + $this->cloner()->duplicate($this->original()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); @@ -100,15 +171,13 @@ final class PostClonerTest extends TestCase public function testDuplicateReportsALeftoverDraftWhenItCannotBeDeleted(): void { Functions\when('get_current_user_id')->justReturn(4); - Functions\when('maybe_unserialize')->returnArg(); Functions\when('wp_insert_post')->justReturn(77); - Functions\when('get_post_meta')->justReturn([]); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn(new WP_Error('x', 'kaputt')); Functions\when('wp_delete_post')->justReturn(false); try { - (new PostCloner())->duplicate($this->original()); + $this->cloner()->duplicate($this->original()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); @@ -116,100 +185,153 @@ final class PostClonerTest extends TestCase } } + public function testBackupIsAFullCloneInTheTrash(): void + { + Functions\when('get_current_user_id')->justReturn(4); + Functions\expect('wp_insert_post')->once()->with($this->draftMatcher('Team (Sicherung)'), true)->andReturn(88); + $this->meta = [5 => ['_et_pb_use_builder' => ['on'], '_lit_mcp_copy_of' => ['1']]]; + Functions\when('get_object_taxonomies')->justReturn(['category']); + Functions\when('wp_get_object_terms')->justReturn([4]); + Functions\expect('wp_set_object_terms')->once()->with(88, [4], 'category', false); + Functions\expect('update_post_meta')->once()->with(88, '_lit_mcp_backup_of', 5)->andReturn(12); + Functions\expect('wp_trash_post')->once()->with(88)->andReturn(new WP_Post(['ID' => 88])); + Functions\when('get_post_status')->justReturn('trash'); + Functions\expect('wp_delete_post')->never(); + + self::assertSame(88, $this->cloner()->backup($this->original())); + self::assertSame([[88, '_et_pb_use_builder', 'on']], $this->inserts); + } + + public function testABackupThatCannotBeTrashedIsDeletedAgain(): void + { + Functions\when('get_current_user_id')->justReturn(4); + Functions\when('wp_insert_post')->justReturn(88); + Functions\when('get_object_taxonomies')->justReturn([]); + Functions\when('update_post_meta')->justReturn(12); + Functions\when('wp_trash_post')->justReturn(new WP_Post(['ID' => 88])); + Functions\when('get_post_status')->justReturn('draft'); + Functions\expect('wp_delete_post')->once()->with(88, true)->andReturn(new WP_Post(['ID' => 88])); + + try { + $this->cloner()->backup($this->original()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + } + } + public function testReplaceOverwritesContentMetaAndTermsButKeepsTheOriginalsIdentity(): void { - Functions\when('maybe_unserialize')->returnArg(); Functions\expect('wp_update_post')->once()->with([ 'ID' => 5, 'post_title' => 'Team', 'post_content' => 'neu', 'post_excerpt' => 'Neu kurz', ], true)->andReturn(5); - Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5 - ? ['_edit_lock' => ['1'], '_wp_old_slug' => ['alt'], 'role' => ['Alt'], 'city' => ['Trier']] - : ['_lit_mcp_copy_of' => ['5'], '_edit_last' => ['4'], 'role' => ['Neu']]); + $this->meta = [ + 5 => ['_edit_lock' => ['1'], '_wp_old_slug' => ['alt'], 'role' => ['Alt'], 'city' => ['Trier']], + 77 => ['_lit_mcp_copy_of' => ['5'], '_edit_last' => ['4'], 'role' => ['Neu']], + ]; Functions\expect('delete_post_meta')->once()->with(5, 'role'); Functions\expect('delete_post_meta')->once()->with(5, 'city'); - Functions\expect('add_post_meta')->once()->with(5, 'role', 'Neu'); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([12]); Functions\expect('wp_set_object_terms')->once()->with(5, [12], 'category', false); - (new PostCloner())->replace($this->original(), $this->copy()); + $this->cloner()->replace($this->original(), $this->copy()); + + self::assertSame([[5, 'role', 'Neu']], $this->inserts); } - public function testReplaceThrowsWhenWordPressRejectsTheUpdate(): void + public function testAReplaceRejectedByWordPressNamesOnlyThePostRow(): void { Functions\when('wp_update_post')->justReturn(new WP_Error('x', 'kaputt')); Functions\expect('delete_post_meta')->never(); - $this->expectException(RuntimeException::class); - - (new PostCloner())->replace($this->original(), $this->copy()); + try { + $this->cloner()->replace($this->original(), $this->copy()); + self::fail('ReplaceFailed expected'); + } catch (ReplaceFailed $e) { + self::assertSame(['post'], $e->parts); + } } - public function testReplaceThrowsWhenTermsCannotBeSet(): void + public function testAFailingTermStepNamesAllParts(): void { Functions\when('wp_update_post')->justReturn(5); - Functions\when('get_post_meta')->justReturn([]); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([12]); Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt')); - $this->expectException(RuntimeException::class); - - (new PostCloner())->replace($this->original(), $this->copy()); + try { + $this->cloner()->replace($this->original(), $this->copy()); + self::fail('ReplaceFailed expected'); + } catch (ReplaceFailed $e) { + self::assertSame(['post', 'meta', 'terms'], $e->parts); + } } public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void { - $meta = ['_edit_lock' => ['1'], 'role' => ['Alt']]; - Functions\when('get_post_meta')->alias(static function () use (&$meta): array { - return $meta; - }); + $this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['b:0;']]]; Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([4]); - $cloner = new PostCloner(); + $cloner = $this->cloner(); $snapshot = $cloner->snapshot($this->original()); self::assertSame(['post_title' => 'Team', 'post_content' => '<!-- wp:divi/section /-->', 'post_excerpt' => 'Kurz'], $snapshot['post']); - self::assertSame(['role' => ['Alt']], $snapshot['meta']); + self::assertSame(['role' => ['b:0;']], $snapshot['meta']); self::assertSame(['category' => [4]], $snapshot['terms']); - $meta = ['_edit_lock' => ['1'], 'role' => ['Neu'], 'city' => ['Trier']]; - Functions\when('maybe_unserialize')->returnArg(); + $this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['Neu'], 'city' => ['Trier']]]; Functions\expect('wp_update_post')->once() ->with(array_merge(['ID' => 5], $snapshot['post']), true)->andReturn(5); Functions\expect('delete_post_meta')->once()->with(5, 'role'); Functions\expect('delete_post_meta')->once()->with(5, 'city'); - Functions\expect('add_post_meta')->once()->with(5, 'role', 'Alt'); Functions\expect('wp_set_object_terms')->once()->with(5, [4], 'category', false); $cloner->restore($this->original(), $snapshot); + + self::assertSame([[5, 'role', 'b:0;']], $this->inserts); + } + + public function testRestoreWritesOnlyThePartsReached(): void + { + Functions\expect('wp_update_post')->once()->andReturn(5); + Functions\expect('delete_post_meta')->never(); + Functions\expect('wp_set_object_terms')->never(); + + $this->cloner()->restore($this->original(), [ + 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], + 'meta' => ['role' => ['Alt']], + 'terms' => ['category' => [4]], + ], ['post']); + + self::assertSame([], $this->inserts); } public function testRestoreTriesEveryPartAndReportsFailures(): void { Functions\when('wp_update_post')->justReturn(new WP_Error('x', 'kaputt')); - Functions\when('get_post_meta')->justReturn([]); Functions\expect('wp_set_object_terms')->once()->with(5, [4], 'category', false); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Restore incomplete'); - (new PostCloner())->restore($this->original(), [ + $this->cloner()->restore($this->original(), [ 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], 'meta' => [], 'terms' => ['category' => [4]], ]); } - private function draftMatcher(): \Mockery\Matcher\Closure + private function draftMatcher(string $title): \Mockery\Matcher\Closure { - return \Mockery::on(static fn (array $data): bool => $data['post_status'] === 'draft' + return Mockery::on(static fn (array $data): bool => $data['post_status'] === 'draft' && $data['post_type'] === 'page' - && $data['post_title'] === 'Team (Kopie)' + && $data['post_title'] === $title && $data['post_content'] === '<!-- wp:divi/section /-->' && $data['post_excerpt'] === 'Kurz' && $data['post_parent'] === 2 diff --git a/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php index 4abe60c..1f1542e 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php @@ -68,6 +68,17 @@ final class PostDuplicateAbilityTest extends TestCase self::assertSame('lit_forbidden', $result->get_error_code()); } + public function testATrashedOriginalIsRefused(): void + { + $this->original->post_status = 'trash'; + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('duplicate')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + public function testIsIndirectAndNotIdempotent(): void { $ability = $this->ability(Mockery::mock(PostCloner::class), Mockery::mock(ChangeLog::class)); diff --git a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php index 6b8711e..044a969 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php @@ -11,6 +11,7 @@ use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostCloner; use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility; +use LIT\MCP\Integrations\WordPress\Status\ReplaceFailed; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use RuntimeException; @@ -80,10 +81,23 @@ final class PostReplaceWithCopyAbilityTest extends TestCase self::assertSame('lit_validation_failed', $result->get_error_code()); } - public function testATrashedCopyIsRefused(): void + /** @return list<array{0: string, 1: string}> */ + public function refusedStatuses(): array { - $this->copy->post_status = 'trash'; + return [ + 'trashed copy' => ['copy', 'trash'], + 'published copy' => ['copy', 'publish'], + 'scheduled copy' => ['copy', 'future'], + 'trashed original' => ['original', 'trash'], + ]; + } + + /** @dataProvider refusedStatuses */ + public function testRefusesCopiesThatAreNoDraftAndTrashedOriginals(string $which, string $status): void + { + $this->{$which}->post_status = $status; $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('backup')->never(); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); @@ -91,6 +105,21 @@ final class PostReplaceWithCopyAbilityTest extends TestCase self::assertSame('lit_validation_failed', $result->get_error_code()); } + public function testAPendingCopyIsAccepted(): void + { + $this->copy->post_status = 'pending'; + $this->revisionAt900(); + $cloner = Mockery::mock(PostCloner::class); + $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); + $cloner->allows('backup')->andReturn(88); + $cloner->expects('replace'); + Functions\when('wp_trash_post')->justReturn(new WP_Post(['ID' => 77])); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(11); + + self::assertTrue($this->ability($cloner, $log)->execute($this->input())['ok']); + } + public function testAStaleRevOfTheOriginalIsRefused(): void { $cloner = Mockery::mock(PostCloner::class); @@ -125,6 +154,10 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $order[] = 'snapshot'; return self::SNAPSHOT; }); + $cloner->expects('backup')->with($this->original)->andReturnUsing(static function () use (&$order): int { + $order[] = 'backup'; + return 88; + }); $cloner->expects('replace')->with($this->original, $this->copy)->andReturnUsing(static function () use (&$order): void { $order[] = 'replace'; }); @@ -138,7 +171,9 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $result = $this->ability($cloner, $log)->execute($this->input()); - self::assertSame(['snapshot', 'revision', 'replace', 'trash'], $order); + self::assertSame(['snapshot', 'backup', 'revision', 'replace', 'trash'], $order); + self::assertSame(88, $result['backup_id']); + self::assertStringContainsString('Sicherung ID 88 im Papierkorb', $result['summary']); self::assertSame(900, $result['revision_id']); self::assertSame(11, $result['log_id']); self::assertSame(Rev::ofPost($this->original), $result['rev']); @@ -151,6 +186,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $this->revisionAt900(); $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); + $cloner->allows('backup')->andReturn(88); $cloner->expects('replace'); Functions\when('wp_trash_post')->justReturn(false); $log = Mockery::mock(ChangeLog::class); @@ -168,8 +204,9 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $this->revisionAt900(); $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); - $cloner->expects('replace')->andThrow(new RuntimeException('meta kaputt')); - $cloner->expects('restore')->with($this->original, self::SNAPSHOT); + $cloner->allows('backup')->andReturn(88); + $cloner->expects('replace')->andThrow(new ReplaceFailed(['post'], new RuntimeException('row kaputt'))); + $cloner->expects('restore')->with($this->original, self::SNAPSHOT, ['post']); Functions\expect('wp_trash_post')->never(); try { @@ -186,8 +223,9 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $this->revisionAt900(); $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); - $cloner->expects('replace')->andThrow(new RuntimeException('meta kaputt')); - $cloner->expects('restore')->andThrow(new RuntimeException('Restore incomplete: terms')); + $cloner->allows('backup')->andReturn(88); + $cloner->expects('replace')->andThrow(new RuntimeException('hook kaputt')); + $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') @@ -205,11 +243,30 @@ final class PostReplaceWithCopyAbilityTest extends TestCase } } + public function testAFailedBackupAbortsBeforeTheOriginalIsTouched(): void + { + $cloner = Mockery::mock(PostCloner::class); + $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); + $cloner->expects('backup')->andThrow(new WriteFailed('Die Sicherung konnte nicht angelegt werden.', true)); + $cloner->expects('replace')->never(); + $cloner->expects('restore')->never(); + Functions\expect('wp_save_post_revision')->never(); + Functions\expect('wp_trash_post')->never(); + + try { + $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + } + } + public function testRequiresRevAndIsDestructive(): void { $ability = $this->ability(Mockery::mock(PostCloner::class), Mockery::mock(ChangeLog::class)); self::assertContains('rev', $ability->inputSchema()['required']); + self::assertContains('backup_id', $ability->outputSchema()['required']); self::assertTrue($ability->annotations()['destructive']); self::assertFalse($ability->annotations()['idempotent']); self::assertFalse($ability->isDirect());