fix: trashed backup before replace, byte-exact meta copy, partial rollback

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:34:13 +02:00
co-authored by Claude Opus 5.5
parent 4fed69cf65
commit e183874ff7
7 changed files with 490 additions and 152 deletions
+156 -63
View File
@@ -11,10 +11,12 @@ use WP_Error;
use WP_Post; use WP_Post;
/** /**
* Copies entries (draft copy) and swaps a copy's content back into its original. * Copies entries (draft copy, trashed backup) and swaps a copy's content back into its
* Meta is copied raw – Divi flags (_et_pb_*), page template (_wp_page_template), * original. Meta is copied byte-exact: the stored meta_value strings are read and
* featured image (_thumbnail_id) and Pods meta included; Pods relationships stored in * inserted directly via $wpdb, so nothing is unserialized, re-serialized or unslashed on
* the meta table travel with it. Only per-post bookkeeping (SKIP_META) stays put. * 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. * snapshot()/restore() let lit/post-replace-with-copy undo a failed swap.
* Not final so abilities can be tested with a mock. * 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 COPY_OF_META = '_lit_mcp_copy_of';
public const BACKUP_OF_META = '_lit_mcp_backup_of';
public const COPY_SUFFIX = ' (Kopie)'; 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. */ /** Bookkeeping meta that belongs to one post and must never be copied or cleared. */
private const SKIP_META = [ private const SKIP_META = [
'_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date', '_wp_trash_meta_status', '_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, '_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 "<title> (Kopie)" as draft of the current user. All or nothing: if meta or * Creates "<title> (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 public function duplicate(WP_Post $original): int
{ {
$originalId = (int) $original->ID; return $this->cloneAsDraft($original, self::COPY_SUFFIX, self::COPY_OF_META, 'Die Kopie');
$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; /**
* 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');
try { try {
$this->replaceMeta($copyId, $this->ownMeta($originalId)); $trashed = wp_trash_post($backupId);
$this->setTerms($copyId, $this->termIds($original)); if (! $trashed || get_post_status($backupId) !== 'trash') {
if (update_post_meta($copyId, self::COPY_OF_META, $originalId) === false) { throw new RuntimeException('Backup could not be moved to the trash.');
throw new RuntimeException('Copy marker could not be written.');
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$deleted = wp_delete_post($copyId, true); $this->discard($original, $backupId, $e, 'Die Sicherung konnte nicht in den Papierkorb gelegt werden.');
$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 $backupId;
}
return $copyId;
} }
/** /**
* Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy * Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy
* into the original. Status, slug, date and author of the original stay. * 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 public function replace(WP_Post $original, WP_Post $copy): void
{ {
$originalId = (int) $original->ID; $originalId = (int) $original->ID;
$parts = [];
try {
$parts[] = 'post';
$this->updateRow($originalId, [ $this->updateRow($originalId, [
'post_title' => self::withoutSuffix((string) $copy->post_title), 'post_title' => self::withoutSuffix((string) $copy->post_title),
'post_content' => (string) $copy->post_content, 'post_content' => (string) $copy->post_content,
'post_excerpt' => (string) $copy->post_excerpt, 'post_excerpt' => (string) $copy->post_excerpt,
]); ]);
$this->replaceMeta($originalId, $this->ownMeta((int) $copy->ID)); $meta = $this->ownMeta((int) $copy->ID);
$this->setTerms($originalId, $this->termIds($copy)); $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(). * 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 * @throws RuntimeException
*/ */
public function snapshot(WP_Post $post): array 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(). * $snapshot Output of snapshot().
* @param list<string> $parts Subset of PARTS.
* @throws RuntimeException Listing every part that could not be restored. * @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; $postId = (int) $post->ID;
$steps = [ $steps = [
fn () => $this->updateRow($postId, $snapshot['post']), 'post' => fn () => $this->updateRow($postId, $snapshot['post']),
fn () => $this->replaceMeta($postId, $snapshot['meta']), 'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']),
fn () => $this->setTerms($postId, $snapshot['terms']), 'terms' => fn () => $this->setTerms($postId, $snapshot['terms']),
]; ];
$failures = []; $failures = [];
foreach ($steps as $step) { foreach (array_intersect_key($steps, array_flip($parts)) as $step) {
try { try {
$step(); $step();
} catch (Throwable $e) { } catch (Throwable $e) {
@@ -158,6 +159,66 @@ class PostCloner
return (string) preg_replace('/ \(Kopie\)$/u', '', $title); 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 * @param array<string, string> $columns
* @throws RuntimeException * @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 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 = []; $meta = [];
foreach ((array) get_post_meta($postId) as $key => $values) { foreach ((array) $rows as $row) {
if (! in_array((string) $key, self::SKIP_META, true)) { $key = (string) $row['meta_key'];
$meta[(string) $key] = array_values((array) $values); if (! in_array($key, self::SKIP_META, true)) {
$meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value'];
} }
} }
@@ -189,13 +260,15 @@ 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 * @throws RuntimeException
*/ */
private function replaceMeta(int $postId, array $meta): void private function replaceMeta(int $postId, array $meta): void
{ {
$db = $this->db();
try {
foreach (array_keys($this->ownMeta($postId)) as $key) { foreach (array_keys($this->ownMeta($postId)) as $key) {
if (delete_post_meta($postId, $key) === false) { if (delete_post_meta($postId, $key) === false) {
throw new RuntimeException(sprintf('Meta "%s" of post %d could not be deleted.', $key, $postId)); throw new RuntimeException(sprintf('Meta "%s" of post %d could not be deleted.', $key, $postId));
@@ -204,11 +277,21 @@ class PostCloner
foreach ($meta as $key => $values) { foreach ($meta as $key => $values) {
foreach ($values as $value) { foreach ($values as $value) {
if (add_post_meta($postId, $key, wp_slash(maybe_unserialize($value))) === false) { $inserted = $db->insert(
throw new RuntimeException(sprintf('Meta "%s" of post %d could not be written.', $key, $postId)); $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;
}
} }
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status; namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
@@ -125,6 +126,14 @@ final class PostDuplicateAbility implements Ability
return $post; 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'); $type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts');
return $type instanceof WP_Error ? $type : $post; return $type instanceof WP_Error ? $type : $post;
@@ -23,10 +23,12 @@ use WP_Post;
* (spec §6.2). ID, URL, status and menu items of the original stay. rev of the 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 * (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 * 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 * snapshotted and the parts reached are restored if the swap fails (WriteFailed →
* lit_partial_write when the restore was incomplete). A revision is taken first. The * lit_write_failed, or lit_partial_write when the restore was incomplete). Before the
* copy then goes to the trash – refused up front when the trash is disabled, because * swap, a full clone of the original is put into the trash as backup (ruling P12; meta
* wp_trash_post() would delete permanently then. * 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 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 ' 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 ' . '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 ' . 'Revision und eine vollständige Sicherung des Originals (im Papierkorb, backup_id), die Kopie kommt '
. 'lit-content-get ist Pflicht. Erst nach Rückfrage mit confirm: true aufrufen.'; . '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 public function inputSchema(): array
@@ -83,7 +88,7 @@ final class PostReplaceWithCopyAbility implements Ability
public function outputSchema(): ?array public function outputSchema(): ?array
{ {
return ToolResult::schema(); return ToolResult::schema(['backup_id' => ['type' => 'integer']], ['backup_id']);
} }
public function annotations(): array public function annotations(): array
@@ -123,14 +128,18 @@ final class PostReplaceWithCopyAbility implements Ability
throw new WriteFailed(self::REASON, true, $originalId, null, $e); 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); $revisionId = RevisionPoint::capture($originalId);
try { try {
$this->cloner->replace($original, $copy); $this->cloner->replace($original, $copy);
} catch (ReplaceFailed $e) {
$this->rollBack($original, $snapshot, $e->parts, $revisionId, $e->getPrevious() ?? $e);
} catch (Throwable $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( return Errors::invalidInput(
'Die Kopie liegt im Papierkorb.', 'Das Original liegt im Papierkorb.',
['copy_id' => 'Kopie liegt im Papierkorb'], ['original_id' => 'Original liegt im Papierkorb'],
'Kopie in WordPress wiederherstellen oder mit lit-post-duplicate eine neue Kopie erstellen.' '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 * @param array<string, mixed> $input
* @return array<string, mixed> * @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; $originalId = (int) $original->ID;
$copyId = (int) $copy->ID; $copyId = (int) $copy->ID;
@@ -226,9 +243,10 @@ final class PostReplaceWithCopyAbility implements Ability
$updated = get_post($originalId); $updated = get_post($originalId);
$updated = $updated instanceof WP_Post ? $updated : $original; $updated = $updated instanceof WP_Post ? $updated : $original;
$statusNote = sprintf( $statusNote = sprintf(
'; Status bleibt %s, %s.', '; Status bleibt %s, %s, der vorherige Stand liegt als Sicherung ID %d im Papierkorb.',
$original->post_status, $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) $result = ToolResult::ok($summary . $statusNote)
@@ -243,20 +261,27 @@ final class PostReplaceWithCopyAbility implements Ability
->logId(is_int($logId) ? $logId : null) ->logId(is_int($logId) ? $logId : null)
->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw')) ->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw'))
->warnings($warnings) ->warnings($warnings)
->with('backup_id', $backupId)
->toArray(); ->toArray();
} }
/** /**
* @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<mixed>>, terms: array<string, list<int>>}
* $snapshot Output of PostCloner::snapshot(). * $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. * @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; $originalId = (int) $original->ID;
$detail = sprintf('Replace of post %d failed: %s', $originalId, $cause->getMessage()); $detail = sprintf('Replace of post %d failed: %s', $originalId, $cause->getMessage());
try { try {
$this->cloner->restore($original, $snapshot); $this->cloner->restore($original, $snapshot, $parts);
} catch (Throwable $e) { } catch (Throwable $e) {
$ignored = []; $ignored = [];
FollowUp::run( FollowUp::run(
@@ -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);
}
}
@@ -7,13 +7,54 @@ namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\WordPress\Status\PostCloner; use LIT\MCP\Integrations\WordPress\Status\PostCloner;
use LIT\MCP\Integrations\WordPress\Status\ReplaceFailed;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException; use RuntimeException;
use WP_Error; use WP_Error;
use WP_Post; use WP_Post;
final class PostClonerTest extends TestCase 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 private function original(): WP_Post
{ {
return new WP_Post([ return new WP_Post([
@@ -41,33 +82,64 @@ final class PostClonerTest extends TestCase
public function testDuplicateCopiesContentMetaAndTermsAsDraft(): void public function testDuplicateCopiesContentMetaAndTermsAsDraft(): void
{ {
Functions\when('get_current_user_id')->justReturn(4); Functions\when('get_current_user_id')->justReturn(4);
Functions\when('maybe_unserialize')->returnArg(); Functions\expect('wp_insert_post')->once()->with($this->draftMatcher('Team (Kopie)'), true)->andReturn(77);
Functions\expect('wp_insert_post')->once()->with($this->draftMatcher(), true)->andReturn(77); $this->meta = [
Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5 5 => [
? [
'_edit_lock' => ['123:1'], '_edit_lock' => ['123:1'],
'_edit_last' => ['1'], '_edit_last' => ['1'],
'_wp_old_slug' => ['team-alt'], '_wp_old_slug' => ['team-alt'],
'_lit_mcp_backup_of' => ['3'],
'_et_pb_use_builder' => ['on'], '_et_pb_use_builder' => ['on'],
'_et_pb_use_divi_5' => ['on'], '_et_pb_use_divi_5' => ['on'],
'_wp_page_template' => ['page-template-blank.php'], '_wp_page_template' => ['page-template-blank.php'],
'_thumbnail_id' => ['31'], '_thumbnail_id' => ['31'],
'role' => ['Leitung'], '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('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('get_object_taxonomies')->justReturn(['category']);
Functions\when('wp_get_object_terms')->justReturn([4, 9]); Functions\when('wp_get_object_terms')->justReturn([4, 9]);
Functions\expect('wp_set_object_terms')->once()->with(77, [4, 9], 'category', false); 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); 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 public function testDuplicateFailureThrows(): void
@@ -77,20 +149,19 @@ final class PostClonerTest extends TestCase
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
(new PostCloner())->duplicate($this->original()); $this->cloner()->duplicate($this->original());
} }
public function testDuplicateFailingAfterInsertDeletesTheNewDraft(): void public function testDuplicateFailingAfterInsertDeletesTheNewDraft(): void
{ {
Functions\when('get_current_user_id')->justReturn(4); Functions\when('get_current_user_id')->justReturn(4);
Functions\when('maybe_unserialize')->returnArg();
Functions\when('wp_insert_post')->justReturn(77); Functions\when('wp_insert_post')->justReturn(77);
Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5 ? ['role' => ['Leitung']] : []); $this->meta = [5 => ['role' => ['Leitung']]];
Functions\when('add_post_meta')->justReturn(false); $this->insertFails = true;
Functions\expect('wp_delete_post')->once()->with(77, true)->andReturn(new WP_Post(['ID' => 77])); Functions\expect('wp_delete_post')->once()->with(77, true)->andReturn(new WP_Post(['ID' => 77]));
try { try {
(new PostCloner())->duplicate($this->original()); $this->cloner()->duplicate($this->original());
self::fail('WriteFailed expected'); self::fail('WriteFailed expected');
} catch (WriteFailed $e) { } catch (WriteFailed $e) {
self::assertTrue($e->rolledBack); self::assertTrue($e->rolledBack);
@@ -100,15 +171,13 @@ final class PostClonerTest extends TestCase
public function testDuplicateReportsALeftoverDraftWhenItCannotBeDeleted(): void public function testDuplicateReportsALeftoverDraftWhenItCannotBeDeleted(): void
{ {
Functions\when('get_current_user_id')->justReturn(4); Functions\when('get_current_user_id')->justReturn(4);
Functions\when('maybe_unserialize')->returnArg();
Functions\when('wp_insert_post')->justReturn(77); Functions\when('wp_insert_post')->justReturn(77);
Functions\when('get_post_meta')->justReturn([]);
Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('get_object_taxonomies')->justReturn(['category']);
Functions\when('wp_get_object_terms')->justReturn(new WP_Error('x', 'kaputt')); Functions\when('wp_get_object_terms')->justReturn(new WP_Error('x', 'kaputt'));
Functions\when('wp_delete_post')->justReturn(false); Functions\when('wp_delete_post')->justReturn(false);
try { try {
(new PostCloner())->duplicate($this->original()); $this->cloner()->duplicate($this->original());
self::fail('WriteFailed expected'); self::fail('WriteFailed expected');
} catch (WriteFailed $e) { } catch (WriteFailed $e) {
self::assertFalse($e->rolledBack); 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 public function testReplaceOverwritesContentMetaAndTermsButKeepsTheOriginalsIdentity(): void
{ {
Functions\when('maybe_unserialize')->returnArg();
Functions\expect('wp_update_post')->once()->with([ Functions\expect('wp_update_post')->once()->with([
'ID' => 5, 'ID' => 5,
'post_title' => 'Team', 'post_title' => 'Team',
'post_content' => 'neu', 'post_content' => 'neu',
'post_excerpt' => 'Neu kurz', 'post_excerpt' => 'Neu kurz',
], true)->andReturn(5); ], true)->andReturn(5);
Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5 $this->meta = [
? ['_edit_lock' => ['1'], '_wp_old_slug' => ['alt'], 'role' => ['Alt'], 'city' => ['Trier']] 5 => ['_edit_lock' => ['1'], '_wp_old_slug' => ['alt'], 'role' => ['Alt'], 'city' => ['Trier']],
: ['_lit_mcp_copy_of' => ['5'], '_edit_last' => ['4'], 'role' => ['Neu']]); 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, 'role');
Functions\expect('delete_post_meta')->once()->with(5, 'city'); 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('get_object_taxonomies')->justReturn(['category']);
Functions\when('wp_get_object_terms')->justReturn([12]); Functions\when('wp_get_object_terms')->justReturn([12]);
Functions\expect('wp_set_object_terms')->once()->with(5, [12], 'category', false); 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\when('wp_update_post')->justReturn(new WP_Error('x', 'kaputt'));
Functions\expect('delete_post_meta')->never(); Functions\expect('delete_post_meta')->never();
$this->expectException(RuntimeException::class); try {
$this->cloner()->replace($this->original(), $this->copy());
(new PostCloner())->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('wp_update_post')->justReturn(5);
Functions\when('get_post_meta')->justReturn([]);
Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('get_object_taxonomies')->justReturn(['category']);
Functions\when('wp_get_object_terms')->justReturn([12]); Functions\when('wp_get_object_terms')->justReturn([12]);
Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt')); Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt'));
$this->expectException(RuntimeException::class); try {
$this->cloner()->replace($this->original(), $this->copy());
(new PostCloner())->replace($this->original(), $this->copy()); self::fail('ReplaceFailed expected');
} catch (ReplaceFailed $e) {
self::assertSame(['post', 'meta', 'terms'], $e->parts);
}
} }
public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void
{ {
$meta = ['_edit_lock' => ['1'], 'role' => ['Alt']]; $this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['b:0;']]];
Functions\when('get_post_meta')->alias(static function () use (&$meta): array {
return $meta;
});
Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('get_object_taxonomies')->justReturn(['category']);
Functions\when('wp_get_object_terms')->justReturn([4]); Functions\when('wp_get_object_terms')->justReturn([4]);
$cloner = new PostCloner(); $cloner = $this->cloner();
$snapshot = $cloner->snapshot($this->original()); $snapshot = $cloner->snapshot($this->original());
self::assertSame(['post_title' => 'Team', 'post_content' => '<!-- wp:divi/section /-->', 'post_excerpt' => 'Kurz'], $snapshot['post']); 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']); self::assertSame(['category' => [4]], $snapshot['terms']);
$meta = ['_edit_lock' => ['1'], 'role' => ['Neu'], 'city' => ['Trier']]; $this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['Neu'], 'city' => ['Trier']]];
Functions\when('maybe_unserialize')->returnArg();
Functions\expect('wp_update_post')->once() Functions\expect('wp_update_post')->once()
->with(array_merge(['ID' => 5], $snapshot['post']), true)->andReturn(5); ->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, 'role');
Functions\expect('delete_post_meta')->once()->with(5, 'city'); 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); Functions\expect('wp_set_object_terms')->once()->with(5, [4], 'category', false);
$cloner->restore($this->original(), $snapshot); $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 public function testRestoreTriesEveryPartAndReportsFailures(): void
{ {
Functions\when('wp_update_post')->justReturn(new WP_Error('x', 'kaputt')); 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); Functions\expect('wp_set_object_terms')->once()->with(5, [4], 'category', false);
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Restore incomplete'); $this->expectExceptionMessage('Restore incomplete');
(new PostCloner())->restore($this->original(), [ $this->cloner()->restore($this->original(), [
'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''],
'meta' => [], 'meta' => [],
'terms' => ['category' => [4]], '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_type'] === 'page'
&& $data['post_title'] === 'Team (Kopie)' && $data['post_title'] === $title
&& $data['post_content'] === '<!-- wp:divi/section /-->' && $data['post_content'] === '<!-- wp:divi/section /-->'
&& $data['post_excerpt'] === 'Kurz' && $data['post_excerpt'] === 'Kurz'
&& $data['post_parent'] === 2 && $data['post_parent'] === 2
@@ -68,6 +68,17 @@ final class PostDuplicateAbilityTest extends TestCase
self::assertSame('lit_forbidden', $result->get_error_code()); 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 public function testIsIndirectAndNotIdempotent(): void
{ {
$ability = $this->ability(Mockery::mock(PostCloner::class), Mockery::mock(ChangeLog::class)); $ability = $this->ability(Mockery::mock(PostCloner::class), Mockery::mock(ChangeLog::class));
@@ -11,6 +11,7 @@ use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostCloner; use LIT\MCP\Integrations\WordPress\Status\PostCloner;
use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility; use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility;
use LIT\MCP\Integrations\WordPress\Status\ReplaceFailed;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
use RuntimeException; use RuntimeException;
@@ -80,10 +81,23 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
self::assertSame('lit_validation_failed', $result->get_error_code()); 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 = Mockery::mock(PostCloner::class);
$cloner->expects('backup')->never();
$cloner->expects('replace')->never(); $cloner->expects('replace')->never();
$result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); $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()); 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 public function testAStaleRevOfTheOriginalIsRefused(): void
{ {
$cloner = Mockery::mock(PostCloner::class); $cloner = Mockery::mock(PostCloner::class);
@@ -125,6 +154,10 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
$order[] = 'snapshot'; $order[] = 'snapshot';
return self::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 { $cloner->expects('replace')->with($this->original, $this->copy)->andReturnUsing(static function () use (&$order): void {
$order[] = 'replace'; $order[] = 'replace';
}); });
@@ -138,7 +171,9 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
$result = $this->ability($cloner, $log)->execute($this->input()); $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(900, $result['revision_id']);
self::assertSame(11, $result['log_id']); self::assertSame(11, $result['log_id']);
self::assertSame(Rev::ofPost($this->original), $result['rev']); self::assertSame(Rev::ofPost($this->original), $result['rev']);
@@ -151,6 +186,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
$this->revisionAt900(); $this->revisionAt900();
$cloner = Mockery::mock(PostCloner::class); $cloner = Mockery::mock(PostCloner::class);
$cloner->allows('snapshot')->andReturn(self::SNAPSHOT); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT);
$cloner->allows('backup')->andReturn(88);
$cloner->expects('replace'); $cloner->expects('replace');
Functions\when('wp_trash_post')->justReturn(false); Functions\when('wp_trash_post')->justReturn(false);
$log = Mockery::mock(ChangeLog::class); $log = Mockery::mock(ChangeLog::class);
@@ -168,8 +204,9 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
$this->revisionAt900(); $this->revisionAt900();
$cloner = Mockery::mock(PostCloner::class); $cloner = Mockery::mock(PostCloner::class);
$cloner->allows('snapshot')->andReturn(self::SNAPSHOT); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT);
$cloner->expects('replace')->andThrow(new RuntimeException('meta kaputt')); $cloner->allows('backup')->andReturn(88);
$cloner->expects('restore')->with($this->original, self::SNAPSHOT); $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(); Functions\expect('wp_trash_post')->never();
try { try {
@@ -186,8 +223,9 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
$this->revisionAt900(); $this->revisionAt900();
$cloner = Mockery::mock(PostCloner::class); $cloner = Mockery::mock(PostCloner::class);
$cloner->allows('snapshot')->andReturn(self::SNAPSHOT); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT);
$cloner->expects('replace')->andThrow(new RuntimeException('meta kaputt')); $cloner->allows('backup')->andReturn(88);
$cloner->expects('restore')->andThrow(new RuntimeException('Restore incomplete: terms')); $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(); Functions\expect('wp_trash_post')->never();
$log = Mockery::mock(ChangeLog::class); $log = Mockery::mock(ChangeLog::class);
$log->expects('record') $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 public function testRequiresRevAndIsDestructive(): void
{ {
$ability = $this->ability(Mockery::mock(PostCloner::class), Mockery::mock(ChangeLog::class)); $ability = $this->ability(Mockery::mock(PostCloner::class), Mockery::mock(ChangeLog::class));
self::assertContains('rev', $ability->inputSchema()['required']); self::assertContains('rev', $ability->inputSchema()['required']);
self::assertContains('backup_id', $ability->outputSchema()['required']);
self::assertTrue($ability->annotations()['destructive']); self::assertTrue($ability->annotations()['destructive']);
self::assertFalse($ability->annotations()['idempotent']); self::assertFalse($ability->annotations()['idempotent']);
self::assertFalse($ability->isDirect()); self::assertFalse($ability->isDirect());