From 4fed69cf654732b6efcc12488e642a65b3766091 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 23:27:07 +0200 Subject: [PATCH] feat: draft copies and replace-with-copy Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Status/PostCloner.php | 249 ++++++++++++++ .../WordPress/Status/PostDuplicateAbility.php | 132 ++++++++ .../Status/PostReplaceWithCopyAbility.php | 311 ++++++++++++++++++ .../WordPress/WordPressIntegration.php | 6 + .../WordPress/Status/PostClonerTest.php | 219 ++++++++++++ .../Status/PostDuplicateAbilityTest.php | 79 +++++ .../Status/PostReplaceWithCopyAbilityTest.php | 217 ++++++++++++ .../WordPress/WordPressIntegrationTest.php | 2 + 8 files changed, 1215 insertions(+) create mode 100644 src/Integrations/WordPress/Status/PostCloner.php create mode 100644 src/Integrations/WordPress/Status/PostDuplicateAbility.php create mode 100644 src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php create mode 100644 tests/Unit/Integrations/WordPress/Status/PostClonerTest.php create mode 100644 tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php create mode 100644 tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php diff --git a/src/Integrations/WordPress/Status/PostCloner.php b/src/Integrations/WordPress/Status/PostCloner.php new file mode 100644 index 0000000..3b350a7 --- /dev/null +++ b/src/Integrations/WordPress/Status/PostCloner.php @@ -0,0 +1,249 @@ + (Kopie)" as draft of the current user. All or nothing: if meta or + * terms fail after the insert, the new draft is deleted again. + * + * @throws WriteFailed + */ + 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); + + 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) + ); + } + + $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.'); + } + } 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); + } + + return $copyId; + } + + /** + * 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 + */ + 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)); + } + + /** + * Everything replace() overwrites – call before replace(). + * + * @return array{post: array, meta: array>, terms: array>} + * @throws RuntimeException + */ + public function snapshot(WP_Post $post): array + { + return [ + 'post' => [ + 'post_title' => (string) $post->post_title, + 'post_content' => (string) $post->post_content, + 'post_excerpt' => (string) $post->post_excerpt, + ], + 'meta' => $this->ownMeta((int) $post->ID), + 'terms' => $this->termIds($post), + ]; + } + + /** + * Writes a snapshot back. Tries every part even if one fails. + * + * @param array{post: array, meta: array>, terms: array>} + * $snapshot Output of snapshot(). + * @throws RuntimeException Listing every part that could not be restored. + */ + public function restore(WP_Post $post, array $snapshot): void + { + $postId = (int) $post->ID; + $steps = [ + fn () => $this->updateRow($postId, $snapshot['post']), + fn () => $this->replaceMeta($postId, $snapshot['meta']), + fn () => $this->setTerms($postId, $snapshot['terms']), + ]; + $failures = []; + foreach ($steps as $step) { + try { + $step(); + } catch (Throwable $e) { + $failures[] = $e->getMessage(); + } + } + + if ($failures !== []) { + throw new RuntimeException('Restore incomplete: ' . implode('; ', $failures)); + } + } + + public static function withoutSuffix(string $title): string + { + return (string) preg_replace('/ \(Kopie\)$/u', '', $title); + } + + /** + * @param array $columns + * @throws RuntimeException + */ + private function updateRow(int $postId, array $columns): void + { + $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)); + } + } + + /** + * Raw meta of a post without SKIP_META (values as stored, serialized ones as strings). + * + * @return array> + */ + private function ownMeta(int $postId): array + { + $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); + } + } + + return $meta; + } + + /** + * Makes the post's meta (except SKIP_META) exactly $meta. + * + * @param array> $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)); + } + } + } + } + + /** + * @return array> Taxonomy => term IDs, for every taxonomy of the post type. + * @throws RuntimeException + */ + private function termIds(WP_Post $post): array + { + $terms = []; + foreach (get_object_taxonomies((string) $post->post_type) as $taxonomy) { + $ids = wp_get_object_terms((int) $post->ID, $taxonomy, ['fields' => 'ids']); + if ($ids instanceof WP_Error) { + throw new RuntimeException( + sprintf('Terms (%s) could not be read: %s', $taxonomy, $ids->get_error_message()) + ); + } + $terms[(string) $taxonomy] = array_map('intval', (array) $ids); + } + + return $terms; + } + + /** + * @param array> $terms + * @throws RuntimeException + */ + private function setTerms(int $postId, array $terms): void + { + foreach ($terms as $taxonomy => $ids) { + $result = wp_set_object_terms($postId, $ids, $taxonomy, false); + if ($result instanceof WP_Error) { + throw new RuntimeException( + sprintf('Terms (%s) could not be set: %s', $taxonomy, $result->get_error_message()) + ); + } + } + } +} diff --git a/src/Integrations/WordPress/Status/PostDuplicateAbility.php b/src/Integrations/WordPress/Status/PostDuplicateAbility.php new file mode 100644 index 0000000..704cadf --- /dev/null +++ b/src/Integrations/WordPress/Status/PostDuplicateAbility.php @@ -0,0 +1,132 @@ + 'object', + 'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['original_id' => ['type' => 'integer']], ['original_id']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $post = $this->load($input); + + return $post instanceof WP_Error ? $post : true; + } + + public function execute(array $input): mixed + { + $original = $this->load($input); + if ($original instanceof WP_Error) { + return $original; + } + + $copyId = $this->cloner->duplicate($original); + + // The copy is saved – failures from here on are logged and reported as warnings only. + $warnings = []; + FollowUp::run( + fn () => do_action('lit_mcp_after_save', $copyId, $this->name(), $input), + $this->name() . ' lit_mcp_after_save', + 'Eine Erweiterung ist nach dem Anlegen der Kopie auf einen Fehler gestoßen; die Kopie ist gespeichert.', + $warnings + ); + + $copy = get_post($copyId); + $copyTitle = $copy instanceof WP_Post ? (string) $copy->post_title : ''; + $postType = (string) $original->post_type; + $summary = sprintf('Kopie von "%s" (ID %d) angelegt', $original->post_title, $original->ID); + $logId = FollowUp::run( + fn () => $this->log->record($this->name(), $postType, (string) $copyId, $summary), + $this->name() . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; die Kopie ist gespeichert.', + $warnings + ); + + return ToolResult::ok(sprintf('%s: Entwurf ID %d.', $summary, $copyId)) + ->changed((string) $copyId, $postType, 'created', $copyTitle) + ->rev($copy instanceof WP_Post ? Rev::ofPost($copy) : null) + ->logId(is_int($logId) ? $logId : null) + ->urls((string) get_preview_post_link($copyId), (string) get_edit_post_link($copyId, 'raw')) + ->warnings($warnings) + ->with('original_id', (int) $original->ID) + ->toArray(); + } + + /** @param array $input */ + private function load(array $input): WP_Post|WP_Error + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + if ($post instanceof WP_Error) { + return $post; + } + + $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 new file mode 100644 index 0000000..99bd823 --- /dev/null +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -0,0 +1,311 @@ +trashEnabled = $trashEnabled + ?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS; + } + + public function name(): string + { + return 'lit/post-replace-with-copy'; + } + + public function label(): string + { + return 'Kopie ins Original übernehmen'; + } + + public function description(): string + { + 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.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'original_id' => ['type' => 'integer', 'minimum' => 1], + 'copy_id' => ['type' => 'integer', 'minimum' => 1], + 'rev' => [ + 'type' => 'string', + 'description' => 'rev des Originals aus lit-content-get (optimistische Sperre).', + ], + 'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'], + ], + 'required' => ['original_id', 'copy_id', 'rev'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $pair = $this->load($input); + + return $pair instanceof WP_Error ? $pair : true; + } + + public function execute(array $input): mixed + { + $pair = $this->load($input); + if ($pair instanceof WP_Error) { + return $pair; + } + + [$original, $copy] = $pair; + $refusal = $this->refusal($original, $copy, $input); + if ($refusal !== null) { + return $refusal; + } + + $originalId = (int) $original->ID; + try { + $snapshot = $this->cloner->snapshot($original); + } catch (Throwable $e) { + throw new WriteFailed(self::REASON, true, $originalId, null, $e); + } + + $revisionId = RevisionPoint::capture($originalId); + try { + $this->cloner->replace($original, $copy); + } catch (Throwable $e) { + $this->rollBack($original, $snapshot, $revisionId, $e); + } + + return $this->result($original, $copy, $input, $revisionId); + } + + /** + * Checks that need no write, in the order a client should fix them. + * + * @param array $input + */ + private function refusal(WP_Post $original, WP_Post $copy, array $input): ?WP_Error + { + $originalId = (int) $original->ID; + $copyId = (int) $copy->ID; + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired(sprintf( + 'Inhalt von "%s" (ID %d) durch die Kopie ID %d ersetzen und die Kopie in den Papierkorb legen?', + $original->post_title, + $originalId, + $copyId + )); + } + + $copyOf = (int) get_post_meta($copyId, PostCloner::COPY_OF_META, true); + if ($copy->post_type !== $original->post_type || $copyOf !== $originalId || $copyId === $originalId) { + return Errors::invalidInput( + 'Die Kopie gehört nicht zu diesem Original.', + ['copy_id' => sprintf('keine mit lit-post-duplicate erstellte Kopie von %d', $originalId)], + 'Kopie mit lit-post-duplicate aus diesem Original erstellen.' + ); + } + + if ($copy->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.' + ); + } + + if (! isset($input['rev']) || $input['rev'] !== Rev::ofPost($original)) { + return Errors::revMismatch(); + } + + if (! ($this->trashEnabled)()) { + return Errors::unsupported( + 'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. ' + . 'Nichts wurde geändert.', + 'Den Austausch in WordPress manuell durchführen.' + ); + } + + return null; + } + + /** + * @param array $input + * @return array + */ + private function result(WP_Post $original, WP_Post $copy, array $input, ?int $revisionId): array + { + $originalId = (int) $original->ID; + $copyId = (int) $copy->ID; + $postType = (string) $original->post_type; + + // The swap is saved – failures from here on are reported as warnings only (ruling P7). + $warnings = []; + $trashWarning = sprintf('Kopie %d konnte nicht in den Papierkorb gelegt werden', $copyId); + $ignored = []; + $trashed = (bool) FollowUp::run( + static fn () => wp_trash_post($copyId), + $this->name() . ' trash copy', + $trashWarning, + $ignored + ); + if (! $trashed) { + $warnings[] = $trashWarning; + } + + FollowUp::run( + fn () => do_action('lit_mcp_after_save', $originalId, $this->name(), $input), + $this->name() . ' lit_mcp_after_save', + 'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; die Änderung ist gespeichert.', + $warnings + ); + + $title = PostCloner::withoutSuffix((string) $copy->post_title); + $summary = sprintf('Kopie ID %d in "%s" (ID %d) übernommen', $copyId, $title, $originalId); + $logId = FollowUp::run( + fn () => $this->log->record($this->name(), $postType, (string) $originalId, $summary, $revisionId), + $this->name() . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', + $warnings + ); + + $updated = get_post($originalId); + $updated = $updated instanceof WP_Post ? $updated : $original; + $statusNote = sprintf( + '; Status bleibt %s, %s.', + $original->post_status, + $trashed ? 'Kopie liegt im Papierkorb' : 'Kopie besteht weiter' + ); + + $result = ToolResult::ok($summary . $statusNote) + ->changed((string) $originalId, $postType, 'replaced', $title); + if ($trashed) { + $result->changed((string) $copyId, $postType, 'trashed', (string) $copy->post_title); + } + + return $result + ->rev(Rev::ofPost($updated)) + ->revisionId($revisionId) + ->logId(is_int($logId) ? $logId : null) + ->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw')) + ->warnings($warnings) + ->toArray(); + } + + /** + * @param array{post: array, meta: array>, terms: array>} + * $snapshot Output of PostCloner::snapshot(). + * @throws WriteFailed Always – rolledBack tells whether the original was restored completely. + */ + private function rollBack(WP_Post $original, array $snapshot, ?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); + } catch (Throwable $e) { + $ignored = []; + FollowUp::run( + fn () => $this->log->record( + $this->name(), + (string) $original->post_type, + (string) $originalId, + sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title), + $revisionId, + null, + 'partial' + ), + $this->name() . ' change log', + '', + $ignored + ); + throw new WriteFailed( + self::REASON, + false, + $originalId, + $revisionId, + $cause, + $detail . '; rollback incomplete: ' . $e->getMessage() + ); + } + + throw new WriteFailed(self::REASON, true, $originalId, $revisionId, $cause, $detail); + } + + /** + * @param array $input + * @return array{0: WP_Post, 1: WP_Post}|WP_Error + */ + private function load(array $input): array|WP_Error + { + $original = PostGuard::editable($input['original_id'] ?? null, $this->schema, 'original_id'); + if ($original instanceof WP_Error) { + return $original; + } + + $copy = PostGuard::editable($input['copy_id'] ?? null, $this->schema, 'copy_id'); + if ($copy instanceof WP_Error) { + return $copy; + } + + if (! current_user_can('delete_post', (int) $copy->ID)) { + return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.'); + } + + return [$original, $copy]; + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index 6fa6447..7746bd1 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -16,7 +16,10 @@ use LIT\MCP\Integrations\WordPress\Content\PostWriter; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\FieldValidator; use LIT\MCP\Integrations\WordPress\Schema\TermAssigner; +use LIT\MCP\Integrations\WordPress\Status\PostCloner; +use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility; use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility; +use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility; use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility; use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility; use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; @@ -45,6 +48,7 @@ final class WordPressIntegration implements Integration { $presenter = new PostPresenter($this->schema); $writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner()); + $cloner = new PostCloner(); return [ new ContentTypesAbility($this->schema), @@ -56,6 +60,8 @@ final class WordPressIntegration implements Integration new PostUnpublishAbility($this->schema, $this->log), new PostScheduleAbility($this->schema, $this->log), new PostTrashAbility($this->schema, $this->log), + new PostDuplicateAbility($this->schema, $cloner, $this->log), + new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log), ]; } } diff --git a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php new file mode 100644 index 0000000..1899100 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php @@ -0,0 +1,219 @@ + 5, + 'post_type' => 'page', + 'post_title' => 'Team', + 'post_content' => '', + 'post_excerpt' => 'Kurz', + 'post_parent' => 2, + 'menu_order' => 3, + ]); + } + + private function copy(): WP_Post + { + return new WP_Post([ + 'ID' => 77, + 'post_type' => 'page', + 'post_title' => 'Team (Kopie)', + 'post_content' => 'neu', + 'post_excerpt' => 'Neu kurz', + ]); + } + + 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 + ? [ + '_edit_lock' => ['123:1'], + '_edit_last' => ['1'], + '_wp_old_slug' => ['team-alt'], + '_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. + 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())); + } + + public function testDuplicateFailureThrows(): void + { + Functions\when('get_current_user_id')->justReturn(4); + Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'kaputt')); + + $this->expectException(RuntimeException::class); + + (new PostCloner())->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); + Functions\expect('wp_delete_post')->once()->with(77, true)->andReturn(new WP_Post(['ID' => 77])); + + try { + (new PostCloner())->duplicate($this->original()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + 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()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(77, $e->postId); + } + } + + 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']]); + 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()); + } + + public function testReplaceThrowsWhenWordPressRejectsTheUpdate(): 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()); + } + + public function testReplaceThrowsWhenTermsCannotBeSet(): 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()); + } + + public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void + { + $meta = ['_edit_lock' => ['1'], 'role' => ['Alt']]; + Functions\when('get_post_meta')->alias(static function () use (&$meta): array { + return $meta; + }); + Functions\when('get_object_taxonomies')->justReturn(['category']); + Functions\when('wp_get_object_terms')->justReturn([4]); + + $cloner = new PostCloner(); + $snapshot = $cloner->snapshot($this->original()); + + self::assertSame(['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => 'Kurz'], $snapshot['post']); + self::assertSame(['role' => ['Alt']], $snapshot['meta']); + self::assertSame(['category' => [4]], $snapshot['terms']); + + $meta = ['_edit_lock' => ['1'], 'role' => ['Neu'], 'city' => ['Trier']]; + Functions\when('maybe_unserialize')->returnArg(); + 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); + } + + 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(), [ + 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], + 'meta' => [], + 'terms' => ['category' => [4]], + ]); + } + + private function draftMatcher(): \Mockery\Matcher\Closure + { + return \Mockery::on(static fn (array $data): bool => $data['post_status'] === 'draft' + && $data['post_type'] === 'page' + && $data['post_title'] === 'Team (Kopie)' + && $data['post_content'] === '' + && $data['post_excerpt'] === 'Kurz' + && $data['post_parent'] === 2 + && $data['menu_order'] === 3 + && $data['post_author'] === 4); + } +} diff --git a/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php new file mode 100644 index 0000000..4abe60c --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php @@ -0,0 +1,79 @@ +original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team']); + $this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)']); + Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=77&preview=true'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=77&action=edit'); + } + + private function ability(PostCloner $cloner, ChangeLog $log): PostDuplicateAbility + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return new PostDuplicateAbility($schema, $cloner, $log); + } + + public function testCreatesADraftCopyAndReportsIt(): void + { + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('duplicate')->with($this->original)->andReturn(77); + Actions\expectDone('lit_mcp_after_save')->once()->with(77, 'lit/post-duplicate', ['post_id' => 5]); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/post-duplicate', 'page', '77', 'Kopie von "Team" (ID 5) angelegt')->andReturn(8); + + $result = $this->ability($cloner, $log)->execute(['post_id' => 5]); + + self::assertSame([['id' => '77', 'type' => 'page', 'action' => 'created', 'label' => 'Team (Kopie)']], $result['changed']); + self::assertSame(5, $result['original_id']); + self::assertSame(8, $result['log_id']); + self::assertSame(Rev::ofPost($this->copy), $result['rev']); + self::assertSame([], $result['warnings']); + } + + public function testNeedsTheRightToCreateEntriesOfThatType(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages'); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('duplicate')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]); + + self::assertSame('lit_forbidden', $result->get_error_code()); + } + + public function testIsIndirectAndNotIdempotent(): void + { + $ability = $this->ability(Mockery::mock(PostCloner::class), Mockery::mock(ChangeLog::class)); + + self::assertFalse($ability->isDirect()); + self::assertFalse($ability->annotations()['idempotent']); + self::assertFalse($ability->annotations()['destructive']); + } +} diff --git a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php new file mode 100644 index 0000000..6b8711e --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php @@ -0,0 +1,217 @@ + ['post_title' => 'Team'], 'meta' => [], 'terms' => []]; + + private WP_Post $original; + private WP_Post $copy; + + protected function setUp(): void + { + parent::setUp(); + $this->original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish']); + $this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']); + Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post_meta')->justReturn('5'); + Functions\when('get_permalink')->justReturn('https://kunde.de/team/'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); + } + + private function ability(PostCloner $cloner, ChangeLog $log, bool $trash = true): PostReplaceWithCopyAbility + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return new PostReplaceWithCopyAbility($schema, $cloner, $log, static fn (): bool => $trash); + } + + /** @return array */ + private function input(array $extra = []): array + { + return array_merge( + ['original_id' => 5, 'copy_id' => 77, 'rev' => Rev::ofPost($this->original), 'confirm' => true], + $extra + ); + } + + private function revisionAt900(): void + { + Functions\when('wp_save_post_revision')->justReturn(900); + Functions\when('wp_get_post_revisions')->justReturn([900 => new WP_Post(['ID' => 900])]); + } + + public function testRequiresConfirmation(): void + { + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class)) + ->execute(['original_id' => 5, 'copy_id' => 77, 'rev' => Rev::ofPost($this->original)]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + } + + public function testCopyMustStemFromTheOriginal(): void + { + Functions\when('get_post_meta')->justReturn('12'); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testATrashedCopyIsRefused(): void + { + $this->copy->post_status = 'trash'; + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testAStaleRevOfTheOriginalIsRefused(): void + { + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->never(); + Functions\expect('wp_save_post_revision')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input(['rev' => 'veraltet'])); + + self::assertSame('lit_rev_mismatch', $result->get_error_code()); + } + + public function testDisabledTrashIsRefusedBeforeAnyChange(): void + { + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class), false)->execute($this->input()); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } + + public function testRevisionThenReplaceThenTrashTheCopy(): void + { + $order = []; + Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) { + $order[] = 'revision'; + return 900; + }); + Functions\when('wp_get_post_revisions')->justReturn([900 => new WP_Post(['ID' => 900])]); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('snapshot')->with($this->original)->andReturnUsing(static function () use (&$order): array { + $order[] = 'snapshot'; + return self::SNAPSHOT; + }); + $cloner->expects('replace')->with($this->original, $this->copy)->andReturnUsing(static function () use (&$order): void { + $order[] = 'replace'; + }); + $cloner->expects('restore')->never(); + Functions\expect('wp_trash_post')->once()->with(77)->andReturnUsing(static function () use (&$order): WP_Post { + $order[] = 'trash'; + return new WP_Post(['ID' => 77]); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900)->andReturn(11); + + $result = $this->ability($cloner, $log)->execute($this->input()); + + self::assertSame(['snapshot', 'revision', 'replace', 'trash'], $order); + self::assertSame(900, $result['revision_id']); + self::assertSame(11, $result['log_id']); + self::assertSame(Rev::ofPost($this->original), $result['rev']); + self::assertSame(['replaced', 'trashed'], array_column($result['changed'], 'action')); + self::assertSame([], $result['warnings']); + } + + public function testAFailedTrashOfTheCopyIsAWarningOnly(): void + { + $this->revisionAt900(); + $cloner = Mockery::mock(PostCloner::class); + $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); + $cloner->expects('replace'); + Functions\when('wp_trash_post')->justReturn(false); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(11); + + $result = $this->ability($cloner, $log)->execute($this->input()); + + self::assertTrue($result['ok']); + self::assertSame(['replaced'], array_column($result['changed'], 'action')); + self::assertSame(['Kopie 77 konnte nicht in den Papierkorb gelegt werden'], $result['warnings']); + } + + public function testAFailedReplaceRestoresTheOriginal(): void + { + $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); + 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::assertTrue($e->rolledBack); + self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + } + } + + public function testAnIncompleteRestoreIsAPartialWrite(): void + { + $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')); + Functions\expect('wp_trash_post')->never(); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record') + ->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900, null, 'partial') + ->andReturn(12); + + try { + $this->ability($cloner, $log)->execute($this->input()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertSame(900, $e->revisionId); + self::assertSame('lit_partial_write', $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::assertTrue($ability->annotations()['destructive']); + self::assertFalse($ability->annotations()['idempotent']); + self::assertFalse($ability->isDirect()); + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index c5ac069..96413f3 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -24,6 +24,8 @@ final class WordPressIntegrationTest extends TestCase 'lit/post-unpublish', 'lit/post-schedule', 'lit/post-trash', + 'lit/post-duplicate', + 'lit/post-replace-with-copy', ]; public function testProvidesTheWordPressAbilitiesInOrder(): void