feat: draft copies and replace-with-copy

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 23:27:07 +02:00
co-authored by Claude Opus 5.5
parent 346619d6da
commit 4fed69cf65
8 changed files with 1215 additions and 0 deletions
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\WriteFailed;
use RuntimeException;
use Throwable;
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.
* snapshot()/restore() let lit/post-replace-with-copy undo a failed swap.
* Not final so abilities can be tested with a mock.
*/
class PostCloner
{
public const COPY_OF_META = '_lit_mcp_copy_of';
public const COPY_SUFFIX = ' (Kopie)';
/** 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,
];
private const DUPLICATE_REASON = 'Die Kopie konnte nicht vollständig angelegt werden.';
/**
* Creates "<title> (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<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
* @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<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
* $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<string, string> $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<string, list<mixed>>
*/
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<string, list<mixed>> $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<string, list<int>> 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<string, list<int>> $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())
);
}
}
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-duplicate: creates a draft copy of an entry (spec §6.2). The original –
* ID, URL, menu items, content – is not touched. A failure after the insert deletes
* the new draft again (PostCloner throws WriteFailed → AbilityRegistrar).
*/
final class PostDuplicateAbility implements Ability
{
public function __construct(
private ContentSchema $schema,
private PostCloner $cloner,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/post-duplicate';
}
public function label(): string
{
return 'Entwurfskopie anlegen';
}
public function description(): string
{
return 'Legt eine Entwurfskopie eines Eintrags an (Inhalt, Felder/Meta, Begriffe, Beitragsbild, Template, '
. 'Divi-Einstellungen). Typischer Ablauf: Kopie bearbeiten, prüfen, dann mit '
. 'lit-post-replace-with-copy ins Original übernehmen – so bleibt die veröffentlichte Seite bis dahin '
. 'unverändert. Die Antwort enthält die rev der Kopie für lit-content-update.';
}
public function inputSchema(): array
{
return [
'type' => '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<string, mixed> $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;
}
}
@@ -0,0 +1,311 @@
<?php
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;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use Throwable;
use WP_Error;
use WP_Post;
/**
* lit/post-replace-with-copy: swaps a copy made by lit/post-duplicate into its original
* (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.
*/
final class PostReplaceWithCopyAbility implements Ability
{
private const REASON = 'Die Kopie konnte nicht ins Original übernommen werden.';
/** @var callable(): bool */
private $trashEnabled;
public function __construct(
private ContentSchema $schema,
private PostCloner $cloner,
private ChangeLog $log,
?callable $trashEnabled = null
) {
$this->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<string, mixed> $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<string, mixed> $input
* @return array<string, mixed>
*/
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<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
* $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<string, mixed> $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];
}
}
@@ -16,7 +16,10 @@ use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator; use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner; 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\PostPublishAbility;
use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility;
use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility; use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility;
use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility; use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility;
use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
@@ -45,6 +48,7 @@ final class WordPressIntegration implements Integration
{ {
$presenter = new PostPresenter($this->schema); $presenter = new PostPresenter($this->schema);
$writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner()); $writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner());
$cloner = new PostCloner();
return [ return [
new ContentTypesAbility($this->schema), new ContentTypesAbility($this->schema),
@@ -56,6 +60,8 @@ final class WordPressIntegration implements Integration
new PostUnpublishAbility($this->schema, $this->log), new PostUnpublishAbility($this->schema, $this->log),
new PostScheduleAbility($this->schema, $this->log), new PostScheduleAbility($this->schema, $this->log),
new PostTrashAbility($this->schema, $this->log), new PostTrashAbility($this->schema, $this->log),
new PostDuplicateAbility($this->schema, $cloner, $this->log),
new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
]; ];
} }
} }
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
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\Tests\Unit\TestCase;
use RuntimeException;
use WP_Error;
use WP_Post;
final class PostClonerTest extends TestCase
{
private function original(): WP_Post
{
return new WP_Post([
'ID' => 5,
'post_type' => 'page',
'post_title' => 'Team',
'post_content' => '<!-- wp:divi/section /-->',
'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' => '<!-- wp:divi/section /-->', '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'] === '<!-- wp:divi/section /-->'
&& $data['post_excerpt'] === 'Kurz'
&& $data['post_parent'] === 2
&& $data['menu_order'] === 3
&& $data['post_author'] === 4);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
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\PostDuplicateAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostDuplicateAbilityTest extends TestCase
{
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']);
$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']);
}
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
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\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;
final class PostReplaceWithCopyAbilityTest extends TestCase
{
private const SNAPSHOT = ['post' => ['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<string, mixed> */
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());
}
}
@@ -24,6 +24,8 @@ final class WordPressIntegrationTest extends TestCase
'lit/post-unpublish', 'lit/post-unpublish',
'lit/post-schedule', 'lit/post-schedule',
'lit/post-trash', 'lit/post-trash',
'lit/post-duplicate',
'lit/post-replace-with-copy',
]; ];
public function testProvidesTheWordPressAbilitiesInOrder(): void public function testProvidesTheWordPressAbilitiesInOrder(): void