fix: never publish a missed schedule, refuse trashed posts, fail on meta read errors
- PostGuard::editable() refuses trashed entries unless the caller opts in (content-get, revisions-list, post-trash); publish/schedule/unpublish/update/ restore/duplicate/replace now refuse them. - PostGuard::statusStable(): content-update, revision-restore and replace-with-copy refuse a future post whose date has passed (any save would publish it); the content-update rollback and PostCloner row writes re-check. - PostCloner::ownMeta() treats a failed SELECT as an error; replace() reads the copy before its first write. - replace-with-copy needs create_posts (backup clone) and reports backup_id in write_failed/partial_write. 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:
@@ -98,15 +98,20 @@ final class Errors
|
|||||||
return self::make('unsupported', $message, $hint, 400);
|
return self::make('unsupported', $message, $hint, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A write failed and was rolled back completely. */
|
/**
|
||||||
public static function writeFailed(string $reason): WP_Error
|
* A write failed and was rolled back completely.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $details Extra error details (e.g. a backup that stays).
|
||||||
|
*/
|
||||||
|
public static function writeFailed(string $reason, array $details = []): WP_Error
|
||||||
{
|
{
|
||||||
return self::make(
|
return self::make(
|
||||||
'write_failed',
|
'write_failed',
|
||||||
trim('Speichern fehlgeschlagen: ' . $reason) . ' Nichts wurde gespeichert.',
|
trim('Speichern fehlgeschlagen: ' . $reason) . ' Nichts wurde gespeichert.',
|
||||||
'Eingaben prüfen, ggf. korrigieren und erneut versuchen; tritt der Fehler wieder auf, den Administrator '
|
'Eingaben prüfen, ggf. korrigieren und erneut versuchen; tritt der Fehler wieder auf, den Administrator '
|
||||||
. 'informieren (Details im PHP-Error-Log der Website).',
|
. 'informieren (Details im PHP-Error-Log der Website).',
|
||||||
500
|
500,
|
||||||
|
$details
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ use WP_Error;
|
|||||||
* written. getMessage() is the internal detail for the PHP error log; $reason is the
|
* written. getMessage() is the internal detail for the PHP error log; $reason is the
|
||||||
* client-safe German text. The AbilityRegistrar logs the detail and returns toError():
|
* client-safe German text. The AbilityRegistrar logs the detail and returns toError():
|
||||||
* lit_write_failed when everything was rolled back, lit_partial_write otherwise.
|
* lit_write_failed when everything was rolled back, lit_partial_write otherwise.
|
||||||
* $state/$hint/$details customise the partial-write error for writes that are not about a
|
* $state/$hint customise the partial-write error for writes that are not about a post (see
|
||||||
* post (see Errors::partialWrite()).
|
* Errors::partialWrite()); $details are added to either error.
|
||||||
*/
|
*/
|
||||||
final class WriteFailed extends RuntimeException
|
final class WriteFailed extends RuntimeException
|
||||||
{
|
{
|
||||||
/** @param array<string, mixed> $details Extra error details for an incomplete rollback. */
|
/** @param array<string, mixed> $details Extra error details for the client. */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $reason,
|
public readonly string $reason,
|
||||||
public readonly bool $rolledBack,
|
public readonly bool $rolledBack,
|
||||||
@@ -41,7 +41,7 @@ final class WriteFailed extends RuntimeException
|
|||||||
public function toError(): WP_Error
|
public function toError(): WP_Error
|
||||||
{
|
{
|
||||||
return $this->rolledBack
|
return $this->rolledBack
|
||||||
? Errors::writeFailed($this->reason)
|
? Errors::writeFailed($this->reason, $this->details)
|
||||||
: Errors::partialWrite(
|
: Errors::partialWrite(
|
||||||
$this->reason,
|
$this->reason,
|
||||||
$this->postId,
|
$this->postId,
|
||||||
|
|||||||
@@ -59,14 +59,14 @@ final class ContentGetAbility implements Ability
|
|||||||
|
|
||||||
public function permission(array $input): bool|WP_Error
|
public function permission(array $input): bool|WP_Error
|
||||||
{
|
{
|
||||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
|
||||||
|
|
||||||
return $post instanceof WP_Error ? $post : true;
|
return $post instanceof WP_Error ? $post : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function execute(array $input): mixed
|
public function execute(array $input): mixed
|
||||||
{
|
{
|
||||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
|
||||||
if ($post instanceof WP_Error) {
|
if ($post instanceof WP_Error) {
|
||||||
return $post;
|
return $post;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ final class ContentUpdateAbility implements Ability
|
|||||||
return Errors::revMismatch();
|
return Errors::revMismatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$unstable = PostGuard::statusStable($post);
|
||||||
|
if ($unstable !== null) {
|
||||||
|
return $unstable;
|
||||||
|
}
|
||||||
|
|
||||||
if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) {
|
if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) {
|
||||||
return Errors::unsupported(
|
return Errors::unsupported(
|
||||||
'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.',
|
'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.',
|
||||||
@@ -279,6 +284,12 @@ final class ContentUpdateAbility implements Ability
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both restore paths save through wp_update_post(), which would publish a missed schedule.
|
||||||
|
$current = get_post((int) $post->ID);
|
||||||
|
if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) {
|
||||||
|
throw new RuntimeException('Post row not restored: saving it now would publish a missed schedule.');
|
||||||
|
}
|
||||||
|
|
||||||
$fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED));
|
$fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED));
|
||||||
if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) {
|
if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) {
|
||||||
$restored = wp_restore_post_revision($revisionId, $fromRevision);
|
$restored = wp_restore_post_revision($revisionId, $fromRevision);
|
||||||
|
|||||||
@@ -16,8 +16,17 @@ use WP_Post;
|
|||||||
*/
|
*/
|
||||||
final class PostGuard
|
final class PostGuard
|
||||||
{
|
{
|
||||||
public static function editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error
|
/**
|
||||||
{
|
* Loads an entry the current user may edit. Trashed entries are refused (every write
|
||||||
|
* on them would be surprising: publishing, scheduling or updating a trashed entry);
|
||||||
|
* readers and lit/post-trash pass $allowTrashed.
|
||||||
|
*/
|
||||||
|
public static function editable(
|
||||||
|
mixed $postId,
|
||||||
|
ContentSchema $schema,
|
||||||
|
string $field = 'post_id',
|
||||||
|
bool $allowTrashed = false
|
||||||
|
): WP_Post|WP_Error {
|
||||||
$id = self::id($postId);
|
$id = self::id($postId);
|
||||||
if ($id === null) {
|
if ($id === null) {
|
||||||
return Errors::invalidInput(
|
return Errors::invalidInput(
|
||||||
@@ -35,9 +44,55 @@ final class PostGuard
|
|||||||
return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id));
|
return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $allowTrashed && $post->post_status === 'trash') {
|
||||||
|
return Errors::invalidInput(
|
||||||
|
sprintf('Eintrag %d liegt im Papierkorb.', $id),
|
||||||
|
[$field => 'Eintrag liegt im Papierkorb'],
|
||||||
|
'Eintrag zuerst in WordPress wiederherstellen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return $post;
|
return $post;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scheduled entry whose publication time has passed (WordPress' cron missed it).
|
||||||
|
* wp_update_post() publishes such an entry on any save, so writes must not touch it.
|
||||||
|
*/
|
||||||
|
public static function missedSchedule(WP_Post $post): bool
|
||||||
|
{
|
||||||
|
$gmt = (string) $post->post_date_gmt;
|
||||||
|
if ($post->post_status !== 'future' || $gmt === '' || $gmt === '0000-00-00 00:00:00') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$time = strtotime($gmt . ' UTC');
|
||||||
|
|
||||||
|
return $time !== false && $time <= time();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses a write that would change the status as a side effect (spec §2: status is only
|
||||||
|
* changed on explicit request) – currently a missed schedule, which any save would publish.
|
||||||
|
*/
|
||||||
|
public static function statusStable(WP_Post $post, string $field = 'post_id'): ?WP_Error
|
||||||
|
{
|
||||||
|
if (! self::missedSchedule($post)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Errors::invalidInput(
|
||||||
|
sprintf(
|
||||||
|
'Eintrag %d ist geplant, der Veröffentlichungszeitpunkt ist aber schon verstrichen; jedes Speichern '
|
||||||
|
. 'würde ihn sofort veröffentlichen. Nichts wurde gespeichert.',
|
||||||
|
(int) $post->ID
|
||||||
|
),
|
||||||
|
[$field => 'geplanter Zeitpunkt verstrichen'],
|
||||||
|
'Beim Nutzer nachfragen: jetzt veröffentlichen (lit-post-publish) oder neu planen (lit/post-schedule '
|
||||||
|
. 'über mcp-adapter-execute-ability); danach die Änderung wiederholen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The brief declares this `: object|WP_Error`, but `object` already covers every
|
* The brief declares this `: object|WP_Error`, but `object` already covers every
|
||||||
* class type, so PHP refuses `object` inside a union ("Type object cannot be
|
* class type, so PHP refuses `object` inside a union ("Type object cannot be
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ final class RevisionRestoreAbility implements Ability
|
|||||||
return Errors::revMismatch();
|
return Errors::revMismatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wp_restore_post_revision() saves via wp_update_post(), which would publish a missed schedule.
|
||||||
|
$unstable = PostGuard::statusStable($post, 'revision_id');
|
||||||
|
if ($unstable !== null) {
|
||||||
|
return $unstable;
|
||||||
|
}
|
||||||
|
|
||||||
$backupId = RevisionPoint::capture($postId);
|
$backupId = RevisionPoint::capture($postId);
|
||||||
$restored = wp_restore_post_revision((int) $revision->ID);
|
$restored = wp_restore_post_revision((int) $revision->ID);
|
||||||
if (! $restored) {
|
if (! $restored) {
|
||||||
|
|||||||
@@ -98,14 +98,14 @@ final class RevisionsListAbility implements Ability
|
|||||||
|
|
||||||
public function permission(array $input): bool|WP_Error
|
public function permission(array $input): bool|WP_Error
|
||||||
{
|
{
|
||||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
|
||||||
|
|
||||||
return $post instanceof WP_Error ? $post : true;
|
return $post instanceof WP_Error ? $post : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function execute(array $input): mixed
|
public function execute(array $input): mixed
|
||||||
{
|
{
|
||||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
|
||||||
if ($post instanceof WP_Error) {
|
if ($post instanceof WP_Error) {
|
||||||
return $post;
|
return $post;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||||
|
|
||||||
use LIT\MCP\Abilities\WriteFailed;
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
@@ -79,7 +80,8 @@ class PostCloner
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 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. Everything is
|
||||||
|
* read before the first write, so a failed read leaves the original untouched.
|
||||||
*
|
*
|
||||||
* @throws ReplaceFailed Naming the parts that were (possibly) written.
|
* @throws ReplaceFailed Naming the parts that were (possibly) written.
|
||||||
*/
|
*/
|
||||||
@@ -88,14 +90,15 @@ class PostCloner
|
|||||||
$originalId = (int) $original->ID;
|
$originalId = (int) $original->ID;
|
||||||
$parts = [];
|
$parts = [];
|
||||||
try {
|
try {
|
||||||
|
$meta = $this->ownMeta((int) $copy->ID);
|
||||||
|
$terms = $this->termIds($copy);
|
||||||
|
$this->assertNoMissedSchedule($originalId);
|
||||||
$parts[] = 'post';
|
$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,
|
||||||
]);
|
]);
|
||||||
$meta = $this->ownMeta((int) $copy->ID);
|
|
||||||
$terms = $this->termIds($copy);
|
|
||||||
$parts[] = 'meta';
|
$parts[] = 'meta';
|
||||||
$this->replaceMeta($originalId, $meta);
|
$this->replaceMeta($originalId, $meta);
|
||||||
$parts[] = 'terms';
|
$parts[] = 'terms';
|
||||||
@@ -225,6 +228,7 @@ class PostCloner
|
|||||||
*/
|
*/
|
||||||
private function updateRow(int $postId, array $columns): void
|
private function updateRow(int $postId, array $columns): void
|
||||||
{
|
{
|
||||||
|
$this->assertNoMissedSchedule($postId);
|
||||||
$result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true);
|
$result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true);
|
||||||
if ($result instanceof WP_Error || ! $result) {
|
if ($result instanceof WP_Error || ! $result) {
|
||||||
$message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error';
|
$message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error';
|
||||||
@@ -232,14 +236,33 @@ class PostCloner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wp_update_post() publishes a scheduled post whose date has passed – never rewrite such a row.
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
private function assertNoMissedSchedule(int $postId): void
|
||||||
|
{
|
||||||
|
$post = get_post($postId);
|
||||||
|
if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Post %d not written: saving it now would publish a missed schedule.',
|
||||||
|
$postId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stored meta of a post without SKIP_META: meta_value strings exactly as in the database.
|
* Stored meta of a post without SKIP_META: meta_value strings exactly as in the database.
|
||||||
|
* A failed query is an error – treating it as "no meta" would wipe meta on replace().
|
||||||
*
|
*
|
||||||
* @return array<string, list<string|null>>
|
* @return array<string, list<string|null>>
|
||||||
|
* @throws RuntimeException
|
||||||
*/
|
*/
|
||||||
private function ownMeta(int $postId): array
|
private function ownMeta(int $postId): array
|
||||||
{
|
{
|
||||||
$db = $this->db();
|
$db = $this->db();
|
||||||
|
$db->last_error = '';
|
||||||
$rows = $db->get_results(
|
$rows = $db->get_results(
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
"SELECT meta_key, meta_value FROM {$db->postmeta} WHERE post_id = %d ORDER BY meta_id",
|
"SELECT meta_key, meta_value FROM {$db->postmeta} WHERE post_id = %d ORDER BY meta_id",
|
||||||
@@ -247,9 +270,16 @@ class PostCloner
|
|||||||
),
|
),
|
||||||
'ARRAY_A'
|
'ARRAY_A'
|
||||||
);
|
);
|
||||||
|
if (! is_array($rows) || (string) $db->last_error !== '') {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Meta of post %d could not be read: %s',
|
||||||
|
$postId,
|
||||||
|
(string) $db->last_error !== '' ? (string) $db->last_error : 'no result'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
$meta = [];
|
$meta = [];
|
||||||
foreach ((array) $rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$key = (string) $row['meta_key'];
|
$key = (string) $row['meta_key'];
|
||||||
if (! in_array($key, self::SKIP_META, true)) {
|
if (! in_array($key, self::SKIP_META, true)) {
|
||||||
$meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value'];
|
$meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value'];
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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;
|
||||||
@@ -126,14 +125,6 @@ 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;
|
||||||
|
|||||||
@@ -134,9 +134,9 @@ final class PostReplaceWithCopyAbility implements Ability
|
|||||||
try {
|
try {
|
||||||
$this->cloner->replace($original, $copy);
|
$this->cloner->replace($original, $copy);
|
||||||
} catch (ReplaceFailed $e) {
|
} catch (ReplaceFailed $e) {
|
||||||
$this->rollBack($original, $snapshot, $e->parts, $revisionId, $e->getPrevious() ?? $e);
|
$this->rollBack($original, $snapshot, $e->parts, $revisionId, $backupId, $e->getPrevious() ?? $e);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $e);
|
$this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $backupId, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->result($original, $copy, $input, $revisionId, $backupId);
|
return $this->result($original, $copy, $input, $revisionId, $backupId);
|
||||||
@@ -169,14 +169,6 @@ final class PostReplaceWithCopyAbility implements Ability
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($original->post_status === 'trash') {
|
|
||||||
return Errors::invalidInput(
|
|
||||||
'Das Original liegt im Papierkorb.',
|
|
||||||
['original_id' => 'Original liegt im Papierkorb'],
|
|
||||||
'Original zuerst in WordPress wiederherstellen.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($copy->post_status, ['draft', 'pending'], true)) {
|
if (! in_array($copy->post_status, ['draft', 'pending'], true)) {
|
||||||
return Errors::invalidInput(
|
return Errors::invalidInput(
|
||||||
sprintf('Die Kopie hat den Status "%s"; übernommen wird nur ein Entwurf.', $copy->post_status),
|
sprintf('Die Kopie hat den Status "%s"; übernommen wird nur ein Entwurf.', $copy->post_status),
|
||||||
@@ -189,6 +181,11 @@ final class PostReplaceWithCopyAbility implements Ability
|
|||||||
return Errors::revMismatch();
|
return Errors::revMismatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$unstable = PostGuard::statusStable($original, 'original_id');
|
||||||
|
if ($unstable !== null) {
|
||||||
|
return $unstable;
|
||||||
|
}
|
||||||
|
|
||||||
if (! ($this->trashEnabled)()) {
|
if (! ($this->trashEnabled)()) {
|
||||||
return Errors::unsupported(
|
return Errors::unsupported(
|
||||||
'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. '
|
'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. '
|
||||||
@@ -269,17 +266,20 @@ final class PostReplaceWithCopyAbility implements Ability
|
|||||||
* @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.
|
* @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;
|
||||||
|
* both name the backup (backup_id), which stays in the trash.
|
||||||
*/
|
*/
|
||||||
private function rollBack(
|
private function rollBack(
|
||||||
WP_Post $original,
|
WP_Post $original,
|
||||||
array $snapshot,
|
array $snapshot,
|
||||||
array $parts,
|
array $parts,
|
||||||
?int $revisionId,
|
?int $revisionId,
|
||||||
|
int $backupId,
|
||||||
Throwable $cause
|
Throwable $cause
|
||||||
): never {
|
): 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());
|
||||||
|
$details = ['backup_id' => $backupId];
|
||||||
try {
|
try {
|
||||||
$this->cloner->restore($original, $snapshot, $parts);
|
$this->cloner->restore($original, $snapshot, $parts);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -296,11 +296,33 @@ final class PostReplaceWithCopyAbility implements Ability
|
|||||||
$originalId,
|
$originalId,
|
||||||
$revisionId,
|
$revisionId,
|
||||||
$cause,
|
$cause,
|
||||||
$detail . '; rollback incomplete: ' . $e->getMessage()
|
$detail . '; rollback incomplete: ' . $e->getMessage(),
|
||||||
|
sprintf(
|
||||||
|
'Eintrag %d ist möglicherweise teilweise geändert worden (Stand vorher: Revision %s); die '
|
||||||
|
. 'vollständige Sicherung des vorherigen Stands liegt als Eintrag %d im Papierkorb.',
|
||||||
|
$originalId,
|
||||||
|
$revisionId === null ? '–' : (string) $revisionId,
|
||||||
|
$backupId
|
||||||
|
),
|
||||||
|
'Den Aufruf nicht einfach wiederholen. Zuerst den Stand mit lit-content-get prüfen; Titel, Inhalt '
|
||||||
|
. 'und Auszug lassen sich mit lit/revision-restore (revision_id) zurückholen, Felder/Meta und '
|
||||||
|
. 'Begriffe stehen in der Sicherung backup_id (in WordPress aus dem Papierkorb holen). Dem '
|
||||||
|
. 'Nutzer Bescheid geben.',
|
||||||
|
$details
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new WriteFailed(self::REASON, true, $originalId, $revisionId, $cause, $detail);
|
throw new WriteFailed(
|
||||||
|
self::REASON,
|
||||||
|
true,
|
||||||
|
$originalId,
|
||||||
|
$revisionId,
|
||||||
|
$cause,
|
||||||
|
$detail,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$details
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,6 +345,9 @@ final class PostReplaceWithCopyAbility implements Ability
|
|||||||
return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.');
|
return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$original, $copy];
|
// The swap first clones the original into a backup entry of the same type.
|
||||||
|
$type = PostGuard::postType((string) $original->post_type, $this->schema, 'create_posts');
|
||||||
|
|
||||||
|
return $type instanceof WP_Error ? $type : [$original, $copy];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ final class PostTrashAbility implements Ability
|
|||||||
/** @param array<string, mixed> $input */
|
/** @param array<string, mixed> $input */
|
||||||
private function load(array $input): WP_Post|WP_Error
|
private function load(array $input): WP_Post|WP_Error
|
||||||
{
|
{
|
||||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
|
||||||
if ($post instanceof WP_Error) {
|
if ($post instanceof WP_Error) {
|
||||||
return $post;
|
return $post;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ if (! class_exists('wpdb')) {
|
|||||||
public $usermeta = 'wp_usermeta';
|
public $usermeta = 'wp_usermeta';
|
||||||
public $options = 'wp_options';
|
public $options = 'wp_options';
|
||||||
public $insert_id = 0;
|
public $insert_id = 0;
|
||||||
|
public $last_error = '';
|
||||||
|
|
||||||
public function prepare($query, ...$args)
|
public function prepare($query, ...$args)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,4 +46,13 @@ final class WriteFailedTest extends TestCase
|
|||||||
self::assertSame('Mit lit-menu-get prüfen.', $error->get_error_data()['hint']);
|
self::assertSame('Mit lit-menu-get prüfen.', $error->get_error_data()['hint']);
|
||||||
self::assertSame(3, $error->get_error_data()['menu_id']);
|
self::assertSame(3, $error->get_error_data()['menu_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCleanRollbackKeepsItsDetails(): void
|
||||||
|
{
|
||||||
|
$error = (new WriteFailed('Nicht übernommen.', true, details: ['backup_id' => 88]))->toError();
|
||||||
|
|
||||||
|
self::assertSame('lit_write_failed', $error->get_error_code());
|
||||||
|
self::assertSame(88, $error->get_error_data()['backup_id']);
|
||||||
|
self::assertStringContainsString('"backup_id":88', $error->get_error_message());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ final class ContentGetAbilityTest extends TestCase
|
|||||||
self::assertSame(['id' => 5], $ability->execute(['post_id' => 5]));
|
self::assertSame(['id' => 5], $ability->execute(['post_id' => 5]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testATrashedPostCanStillBeRead(): void
|
||||||
|
{
|
||||||
|
$post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash']);
|
||||||
|
Functions\when('get_post')->justReturn($post);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$schema = Mockery::mock(ContentSchema::class);
|
||||||
|
$schema->allows('isManaged')->andReturn(true);
|
||||||
|
$presenter = Mockery::mock(PostPresenter::class);
|
||||||
|
$presenter->expects('full')->with($post)->andReturn(['id' => 5]);
|
||||||
|
|
||||||
|
self::assertSame(['id' => 5], (new ContentGetAbility($schema, $presenter))->execute(['post_id' => 5]));
|
||||||
|
}
|
||||||
|
|
||||||
public function testMissingPostIsNotFound(): void
|
public function testMissingPostIsNotFound(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_post')->justReturn(null);
|
Functions\when('get_post')->justReturn(null);
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ final class ContentUpdateAbilityTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testATrashedPostIsRefused(): void
|
||||||
|
{
|
||||||
|
$this->post->post_status = 'trash';
|
||||||
|
Functions\expect('wp_update_post')->never();
|
||||||
|
|
||||||
|
$result = $this->ability(Mockery::mock(PostWriter::class))
|
||||||
|
->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Neu']);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissedScheduleIsRefusedBecauseSavingWouldPublish(): void
|
||||||
|
{
|
||||||
|
$this->post->post_status = 'future';
|
||||||
|
$this->post->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 600);
|
||||||
|
Functions\expect('wp_update_post')->never();
|
||||||
|
Functions\expect('wp_save_post_revision')->never();
|
||||||
|
|
||||||
|
$result = $this->ability(Mockery::mock(PostWriter::class))
|
||||||
|
->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Neu']);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||||
|
self::assertArrayHasKey('post_id', $result->get_error_data()['fields']);
|
||||||
|
self::assertStringContainsString('lit/post-schedule', $result->get_error_data()['hint']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testStaleRevIsRejectedWithoutWriting(): void
|
public function testStaleRevIsRejectedWithoutWriting(): void
|
||||||
{
|
{
|
||||||
Functions\expect('wp_update_post')->never();
|
Functions\expect('wp_update_post')->never();
|
||||||
@@ -254,6 +280,30 @@ final class ContentUpdateAbilityTest extends TestCase
|
|||||||
self::assertSame(['post_name' => 'team', 'ID' => 5], $updates[1]);
|
self::assertSame(['post_name' => 'team', 'ID' => 5], $updates[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheRollbackNeverRewritesARowWhoseScheduleHasPassedMeanwhile(): void
|
||||||
|
{
|
||||||
|
[$writer, $input] = $this->failingWriter(['post_name' => 'unser-team']);
|
||||||
|
$post = $this->post;
|
||||||
|
$updates = [];
|
||||||
|
Functions\when('wp_update_post')->alias(static function (array $data) use (&$updates, $post): int {
|
||||||
|
$updates[] = $data;
|
||||||
|
// The entry turned into a missed schedule while the call ran (e.g. edited in WordPress).
|
||||||
|
$post->post_status = 'future';
|
||||||
|
$post->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 5);
|
||||||
|
return 5;
|
||||||
|
});
|
||||||
|
Functions\when('wp_revisions_enabled')->justReturn(true);
|
||||||
|
Functions\expect('wp_restore_post_revision')->never();
|
||||||
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
|
$log->expects('recordPartial');
|
||||||
|
|
||||||
|
$failure = $this->executeExpectingFailure($writer, $input, $log);
|
||||||
|
|
||||||
|
self::assertFalse($failure->rolledBack);
|
||||||
|
self::assertCount(1, $updates);
|
||||||
|
self::assertStringContainsString('publish', $failure->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
public function testAnIncompleteRollbackIsAPartialWriteAndLogged(): void
|
public function testAnIncompleteRollbackIsAPartialWriteAndLogged(): void
|
||||||
{
|
{
|
||||||
[$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team'], false);
|
[$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team'], false);
|
||||||
|
|||||||
@@ -47,6 +47,44 @@ final class PostGuardTest extends TestCase
|
|||||||
self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code());
|
self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTrashedPostsAreRefusedUnlessAllowed(): void
|
||||||
|
{
|
||||||
|
$post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash']);
|
||||||
|
Functions\when('get_post')->justReturn($post);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
|
||||||
|
$error = PostGuard::editable(5, $this->schema(), 'copy_id');
|
||||||
|
self::assertSame('lit_validation_failed', $error->get_error_code());
|
||||||
|
self::assertArrayHasKey('copy_id', $error->get_error_data()['fields']);
|
||||||
|
self::assertSame($post, PostGuard::editable(5, $this->schema(), 'post_id', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissedScheduleIsAFuturePostWhoseDateHasPassed(): void
|
||||||
|
{
|
||||||
|
$past = gmdate('Y-m-d H:i:s', time() - 3600);
|
||||||
|
$future = gmdate('Y-m-d H:i:s', time() + 3600);
|
||||||
|
|
||||||
|
self::assertTrue(PostGuard::missedSchedule(new WP_Post(['post_status' => 'future', 'post_date_gmt' => $past])));
|
||||||
|
self::assertFalse(PostGuard::missedSchedule(new WP_Post(['post_status' => 'future', 'post_date_gmt' => $future])));
|
||||||
|
self::assertFalse(PostGuard::missedSchedule(new WP_Post(['post_status' => 'draft', 'post_date_gmt' => $past])));
|
||||||
|
self::assertFalse(PostGuard::missedSchedule(
|
||||||
|
new WP_Post(['post_status' => 'future', 'post_date_gmt' => '0000-00-00 00:00:00'])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusStableRefusesAMissedScheduleWithAHint(): void
|
||||||
|
{
|
||||||
|
$post = new WP_Post(['ID' => 5, 'post_status' => 'future', 'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 60)]);
|
||||||
|
|
||||||
|
$error = PostGuard::statusStable($post, 'original_id');
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $error->get_error_code());
|
||||||
|
self::assertArrayHasKey('original_id', $error->get_error_data()['fields']);
|
||||||
|
self::assertStringContainsString('lit-post-publish', $error->get_error_data()['hint']);
|
||||||
|
self::assertStringContainsString('lit/post-schedule', $error->get_error_data()['hint']);
|
||||||
|
self::assertNull(PostGuard::statusStable(new WP_Post(['post_status' => 'publish'])));
|
||||||
|
}
|
||||||
|
|
||||||
public function testPostTypeChecksAvailabilityAndCapability(): void
|
public function testPostTypeChecksAvailabilityAndCapability(): void
|
||||||
{
|
{
|
||||||
$object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']];
|
$object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']];
|
||||||
|
|||||||
@@ -76,6 +76,45 @@ final class RevisionRestoreAbilityTest extends TestCase
|
|||||||
self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']);
|
self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testATrashedEntryIsRefused(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post')->alias(static fn (int $id) => match ($id) {
|
||||||
|
810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5]),
|
||||||
|
5 => new WP_Post(array_merge(self::CURRENT_POST, ['post_status' => 'trash'])),
|
||||||
|
default => null,
|
||||||
|
});
|
||||||
|
Functions\expect('wp_restore_post_revision')->never();
|
||||||
|
|
||||||
|
$error = $this->ability(Mockery::mock(ChangeLog::class))->execute(['revision_id' => 810, 'rev' => 'x', 'confirm' => true]);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $error->get_error_code());
|
||||||
|
self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissedScheduleIsRefusedBecauseRestoringWouldPublish(): void
|
||||||
|
{
|
||||||
|
$current = array_merge(self::CURRENT_POST, [
|
||||||
|
'post_status' => 'future',
|
||||||
|
'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 600),
|
||||||
|
]);
|
||||||
|
Functions\when('get_post')->alias(static fn (int $id) => match ($id) {
|
||||||
|
810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5]),
|
||||||
|
5 => new WP_Post($current),
|
||||||
|
default => null,
|
||||||
|
});
|
||||||
|
Functions\expect('wp_save_post_revision')->never();
|
||||||
|
Functions\expect('wp_restore_post_revision')->never();
|
||||||
|
|
||||||
|
$error = $this->ability(Mockery::mock(ChangeLog::class))->execute([
|
||||||
|
'revision_id' => 810,
|
||||||
|
'rev' => Rev::ofPost(new WP_Post($current)),
|
||||||
|
'confirm' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $error->get_error_code());
|
||||||
|
self::assertStringContainsString('lit-post-publish', $error->get_error_data()['hint']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testMissingOrStaleRevIsRevMismatch(): void
|
public function testMissingOrStaleRevIsRevMismatch(): void
|
||||||
{
|
{
|
||||||
Functions\expect('wp_restore_post_revision')->never();
|
Functions\expect('wp_restore_post_revision')->never();
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ final class RevisionsListAbilityTest extends TestCase
|
|||||||
self::assertSame([PostPresenter::DIVI_HINT], $result['hints']);
|
self::assertSame([PostPresenter::DIVI_HINT], $result['hints']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRevisionsOfATrashedPostCanStillBeListed(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash']));
|
||||||
|
Functions\when('wp_get_post_revisions')->justReturn([]);
|
||||||
|
|
||||||
|
self::assertSame([], $this->ability()->execute(['post_id' => 5])['items']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testInvalidCursorIsRejected(): void
|
public function testInvalidCursorIsRejected(): void
|
||||||
{
|
{
|
||||||
$result = $this->ability()->execute(['post_id' => 5, 'cursor' => 'not-a-cursor']);
|
$result = $this->ability()->execute(['post_id' => 5, 'cursor' => 'not-a-cursor']);
|
||||||
|
|||||||
@@ -24,17 +24,30 @@ final class PostClonerTest extends TestCase
|
|||||||
|
|
||||||
private bool $insertFails = false;
|
private bool $insertFails = false;
|
||||||
|
|
||||||
|
/** Post IDs whose meta SELECT fails: 'null' returns null, 'error' sets $wpdb->last_error. */
|
||||||
|
private array $metaReadFails = [];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
Functions\when('wp_cache_delete')->justReturn(true);
|
Functions\when('wp_cache_delete')->justReturn(true);
|
||||||
|
Functions\when('get_post')->justReturn(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cloner(): PostCloner
|
private function cloner(): PostCloner
|
||||||
{
|
{
|
||||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
$db->allows('get_results')->andReturnUsing(function (string $sql): array {
|
$db->allows('get_results')->andReturnUsing(function (string $sql) use ($db): ?array {
|
||||||
preg_match('/post_id = (\d+)/', $sql, $match);
|
preg_match('/post_id = (\d+)/', $sql, $match);
|
||||||
|
$db->last_error = '';
|
||||||
|
$failure = $this->metaReadFails[(int) $match[1]] ?? null;
|
||||||
|
if ($failure === 'null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($failure === 'error') {
|
||||||
|
$db->last_error = 'Table wp_postmeta is marked as crashed';
|
||||||
|
return [];
|
||||||
|
}
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($this->meta[(int) $match[1]] ?? [] as $key => $values) {
|
foreach ($this->meta[(int) $match[1]] ?? [] as $key => $values) {
|
||||||
foreach ($values as $value) {
|
foreach ($values as $value) {
|
||||||
@@ -246,6 +259,7 @@ final class PostClonerTest extends TestCase
|
|||||||
|
|
||||||
public function testAReplaceRejectedByWordPressNamesOnlyThePostRow(): void
|
public function testAReplaceRejectedByWordPressNamesOnlyThePostRow(): void
|
||||||
{
|
{
|
||||||
|
Functions\when('get_object_taxonomies')->justReturn([]);
|
||||||
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();
|
||||||
|
|
||||||
@@ -272,6 +286,96 @@ final class PostClonerTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{0: string}> */
|
||||||
|
public function metaReadFailures(): array
|
||||||
|
{
|
||||||
|
return ['null result' => ['null'], 'database error' => ['error']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider metaReadFailures */
|
||||||
|
public function testAFailedMetaReadIsAnErrorNotEmptyMeta(string $failure): void
|
||||||
|
{
|
||||||
|
$this->metaReadFails = [5 => $failure];
|
||||||
|
Functions\when('get_object_taxonomies')->justReturn([]);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('Meta of post 5 could not be read');
|
||||||
|
|
||||||
|
$this->cloner()->snapshot($this->original());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider metaReadFailures */
|
||||||
|
public function testReplaceReadsTheCopyBeforeWritingAnything(string $failure): void
|
||||||
|
{
|
||||||
|
$this->metaReadFails = [77 => $failure];
|
||||||
|
Functions\when('get_object_taxonomies')->justReturn([]);
|
||||||
|
Functions\expect('wp_update_post')->never();
|
||||||
|
Functions\expect('delete_post_meta')->never();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->cloner()->replace($this->original(), $this->copy());
|
||||||
|
self::fail('ReplaceFailed expected');
|
||||||
|
} catch (ReplaceFailed $e) {
|
||||||
|
self::assertSame([], $e->parts);
|
||||||
|
self::assertStringContainsString('Meta of post 77 could not be read', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADuplicateWithAFailedMetaReadIsDeletedAgain(): void
|
||||||
|
{
|
||||||
|
$this->metaReadFails = [5 => 'error'];
|
||||||
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
|
Functions\when('wp_insert_post')->justReturn(90);
|
||||||
|
Functions\expect('wp_delete_post')->once()->with(90, true)->andReturn(new WP_Post(['ID' => 90]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->cloner()->duplicate($this->original());
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function missedSchedule(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post([
|
||||||
|
'ID' => 5,
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_status' => 'future',
|
||||||
|
'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 60),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReplaceRefusesAMissedScheduleBeforeWriting(): void
|
||||||
|
{
|
||||||
|
$this->missedSchedule();
|
||||||
|
Functions\when('get_object_taxonomies')->justReturn([]);
|
||||||
|
Functions\expect('wp_update_post')->never();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->cloner()->replace($this->original(), $this->copy());
|
||||||
|
self::fail('ReplaceFailed expected');
|
||||||
|
} catch (ReplaceFailed $e) {
|
||||||
|
self::assertSame([], $e->parts);
|
||||||
|
self::assertStringContainsString('missed schedule', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRestoreNeverRewritesTheRowOfAMissedSchedule(): void
|
||||||
|
{
|
||||||
|
$this->missedSchedule();
|
||||||
|
Functions\expect('wp_update_post')->never();
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('missed schedule');
|
||||||
|
|
||||||
|
$this->cloner()->restore($this->original(), [
|
||||||
|
'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''],
|
||||||
|
'meta' => [],
|
||||||
|
'terms' => [],
|
||||||
|
], ['post']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void
|
public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void
|
||||||
{
|
{
|
||||||
$this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['b:0;']]];
|
$this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['b:0;']]];
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ final class PostPublishAbilityTest extends TestCase
|
|||||||
self::assertStringContainsString('bereits veröffentlicht', $result['summary']);
|
self::assertStringContainsString('bereits veröffentlicht', $result['summary']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testATrashedPostIsRefused(): void
|
||||||
|
{
|
||||||
|
$this->stubPost(['post_status' => 'trash']);
|
||||||
|
Functions\expect('wp_update_post')->never();
|
||||||
|
|
||||||
|
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
public function testPermissionNeedsPublishRights(): void
|
public function testPermissionNeedsPublishRights(): void
|
||||||
{
|
{
|
||||||
$this->stubPost(['post_status' => 'draft']);
|
$this->stubPost(['post_status' => 'draft']);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
|
|||||||
$this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']);
|
$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('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy);
|
||||||
Functions\when('current_user_can')->justReturn(true);
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
|
||||||
Functions\when('get_post_meta')->justReturn('5');
|
Functions\when('get_post_meta')->justReturn('5');
|
||||||
Functions\when('get_permalink')->justReturn('https://kunde.de/team/');
|
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');
|
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
|
||||||
@@ -105,6 +106,20 @@ 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 testAMissedScheduleOfTheOriginalIsRefused(): void
|
||||||
|
{
|
||||||
|
$this->original->post_status = 'future';
|
||||||
|
$this->original->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 600);
|
||||||
|
$cloner = Mockery::mock(PostCloner::class);
|
||||||
|
$cloner->expects('backup')->never();
|
||||||
|
$cloner->expects('replace')->never();
|
||||||
|
|
||||||
|
$result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input());
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||||
|
self::assertArrayHasKey('original_id', $result->get_error_data()['fields']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testAPendingCopyIsAccepted(): void
|
public function testAPendingCopyIsAccepted(): void
|
||||||
{
|
{
|
||||||
$this->copy->post_status = 'pending';
|
$this->copy->post_status = 'pending';
|
||||||
@@ -215,6 +230,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
|
|||||||
} catch (WriteFailed $e) {
|
} catch (WriteFailed $e) {
|
||||||
self::assertTrue($e->rolledBack);
|
self::assertTrue($e->rolledBack);
|
||||||
self::assertSame('lit_write_failed', $e->toError()->get_error_code());
|
self::assertSame('lit_write_failed', $e->toError()->get_error_code());
|
||||||
|
self::assertSame(88, $e->toError()->get_error_data()['backup_id']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +255,26 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
|
|||||||
self::assertFalse($e->rolledBack);
|
self::assertFalse($e->rolledBack);
|
||||||
self::assertSame(5, $e->postId);
|
self::assertSame(5, $e->postId);
|
||||||
self::assertSame(900, $e->revisionId);
|
self::assertSame(900, $e->revisionId);
|
||||||
self::assertSame('lit_partial_write', $e->toError()->get_error_code());
|
$error = $e->toError();
|
||||||
|
self::assertSame('lit_partial_write', $error->get_error_code());
|
||||||
|
self::assertSame(88, $error->get_error_data()['backup_id']);
|
||||||
|
self::assertStringContainsString('Sicherung', $error->get_error_message());
|
||||||
|
self::assertStringContainsString('88', $error->get_error_message());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testNeedsTheRightToCreateEntriesForTheBackup(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages');
|
||||||
|
$cloner = Mockery::mock(PostCloner::class);
|
||||||
|
$cloner->expects('backup')->never();
|
||||||
|
$cloner->expects('replace')->never();
|
||||||
|
$ability = $this->ability($cloner, Mockery::mock(ChangeLog::class));
|
||||||
|
|
||||||
|
self::assertSame('lit_forbidden', $ability->permission($this->input())->get_error_code());
|
||||||
|
self::assertSame('lit_forbidden', $ability->execute($this->input())->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
public function testAFailedBackupAbortsBeforeTheOriginalIsTouched(): void
|
public function testAFailedBackupAbortsBeforeTheOriginalIsTouched(): void
|
||||||
{
|
{
|
||||||
$cloner = Mockery::mock(PostCloner::class);
|
$cloner = Mockery::mock(PostCloner::class);
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ final class PostScheduleAbilityTest extends TestCase
|
|||||||
self::assertStringContainsString('2099-06-01 09:30', $result['summary']);
|
self::assertStringContainsString('2099-06-01 09:30', $result['summary']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testATrashedPostIsRefused(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'trash']));
|
||||||
|
Functions\expect('wp_update_post')->never();
|
||||||
|
|
||||||
|
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
public function testPastAndImpossibleDatesAreRejected(): void
|
public function testPastAndImpossibleDatesAreRejected(): void
|
||||||
{
|
{
|
||||||
Functions\expect('wp_update_post')->never();
|
Functions\expect('wp_update_post')->never();
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ final class PostTrashAbilityTest extends TestCase
|
|||||||
self::assertSame('lit_unsupported', $result->get_error_code());
|
self::assertSame('lit_unsupported', $result->get_error_code());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAnAlreadyTrashedPostIsANoOp(): void
|
||||||
|
{
|
||||||
|
$ability = $this->ability(Mockery::mock(ChangeLog::class));
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Alt', 'post_status' => 'trash']));
|
||||||
|
Functions\expect('wp_trash_post')->never();
|
||||||
|
|
||||||
|
$result = $ability->execute(['post_id' => 5, 'confirm' => true]);
|
||||||
|
|
||||||
|
self::assertSame([], $result['changed']);
|
||||||
|
self::assertStringContainsString('bereits im Papierkorb', $result['summary']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testIsDestructiveAndIndirect(): void
|
public function testIsDestructiveAndIndirect(): void
|
||||||
{
|
{
|
||||||
$ability = $this->ability(Mockery::mock(ChangeLog::class));
|
$ability = $this->ability(Mockery::mock(ChangeLog::class));
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ final class PostUnpublishAbilityTest extends TestCase
|
|||||||
self::assertSame('unpublished', $result['changed'][0]['action']);
|
self::assertSame('unpublished', $result['changed'][0]['action']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testATrashedPostIsRefusedInsteadOfBecomingADraft(): void
|
||||||
|
{
|
||||||
|
Functions\expect('wp_update_post')->never();
|
||||||
|
|
||||||
|
$result = $this->ability(Mockery::mock(ChangeLog::class), 'trash')->execute(['post_id' => 5]);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
public function testDraftIsANoOp(): void
|
public function testDraftIsANoOp(): void
|
||||||
{
|
{
|
||||||
Functions\expect('wp_update_post')->never();
|
Functions\expect('wp_update_post')->never();
|
||||||
|
|||||||
Reference in New Issue
Block a user