feat: explicit publish, unpublish, schedule and trash tools

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:18:35 +02:00
co-authored by Claude Opus 5.5
parent a9b2f2db71
commit 346619d6da
10 changed files with 902 additions and 0 deletions
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-publish: publishes an entry immediately. A scheduled ("future") or
* future-dated entry is republished now instead of staying in the future (spec §6.2);
* an already-published entry is a no-op.
*/
final class PostPublishAbility implements Ability
{
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-publish';
}
public function label(): string
{
return 'Veröffentlichen';
}
public function description(): string
{
return 'Veröffentlicht einen Eintrag sofort; geplante oder zukunftsdatierte Einträge erhalten das '
. 'aktuelle Datum. Nur auf ausdrücklichen Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
$editUrl = (string) get_edit_post_link($postId, 'raw');
if ($post->post_status === 'publish') {
return ToolResult::ok(sprintf('"%s" ist bereits veröffentlicht.', $title))
->rev(Rev::ofPost($post))
->urls((string) get_permalink($postId), $editUrl)
->toArray();
}
$data = ['ID' => $postId, 'post_status' => 'publish'];
if ($post->post_status === 'future' || $this->isFutureDated($post)) {
$data['post_date'] = current_time('mysql');
$data['post_date_gmt'] = current_time('mysql', true);
$data['edit_date'] = true;
}
$result = wp_update_post($data, true);
if ($result instanceof WP_Error) {
return Errors::invalidInput(
'WordPress hat das Veröffentlichen abgelehnt: ' . $result->get_error_message()
);
}
$status = get_post_status($postId);
if ($status !== 'publish') {
return Errors::unsupported(
sprintf('Nach dem Veröffentlichen ist der Status "%s".', (string) $status),
'Eintrag in WordPress prüfen (z. B. Freigabe-Workflow eines anderen Plugins).'
);
}
// The status change is saved – failures from here on are logged and reported as warnings only.
$warnings = [];
FollowUp::run(
fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input),
$this->name() . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Veröffentlichen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$warnings
);
$summary = sprintf('"%s" veröffentlicht', $title);
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok(sprintf('"%s" ist jetzt veröffentlicht.', $title))
->changed((string) $postId, (string) $post->post_type, 'published', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->urls((string) get_permalink($postId), $editUrl)
->warnings($warnings)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('publish_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu veröffentlichen.');
}
private function isFutureDated(WP_Post $post): bool
{
$gmt = (string) $post->post_date_gmt;
return $gmt !== '' && $gmt !== '0000-00-00 00:00:00' && strtotime($gmt . ' UTC') > time();
}
}
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use DateTimeImmutable;
use DateTimeZone;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-schedule: schedules an entry for publication at a future point in time
* (site local time). An entry that is currently published stays live until then only
* if wp_update_post() keeps it that way; WordPress itself takes it offline once the
* status becomes "future".
*/
final class PostScheduleAbility implements Ability
{
private const FORMAT = 'Y-m-d H:i';
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-schedule';
}
public function label(): string
{
return 'Veröffentlichung planen';
}
public function description(): string
{
return 'Plant die Veröffentlichung eines Eintrags zu einem Zeitpunkt in der Zukunft (Ortszeit der '
. 'Website). Ein bereits veröffentlichter Eintrag ist bis dahin offline. Nur auf ausdrücklichen '
. 'Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'date' => [
'type' => 'string',
'pattern' => '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
'description' => 'JJJJ-MM-TT HH:MM in der Zeitzone der Website.',
],
],
'required' => ['post_id', 'date'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$raw = (string) ($input['date'] ?? '');
$date = DateTimeImmutable::createFromFormat('!' . self::FORMAT, $raw, wp_timezone());
if ($date === false || $date->format(self::FORMAT) !== $raw) {
return Errors::invalidInput('Ungültiges Datum.', ['date' => 'Format JJJJ-MM-TT HH:MM erwartet']);
}
if ($date->getTimestamp() <= time()) {
return Errors::invalidInput(
'Der Zeitpunkt muss in der Zukunft liegen.',
['date' => 'liegt nicht in der Zukunft'],
'Zum sofortigen Veröffentlichen lit-post-publish verwenden.'
);
}
$postId = (int) $post->ID;
$result = wp_update_post([
'ID' => $postId,
'post_status' => 'future',
'post_date' => $date->format('Y-m-d H:i:s'),
'post_date_gmt' => $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'),
'edit_date' => true,
], true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Planen abgelehnt: ' . $result->get_error_message());
}
if (get_post_status($postId) !== 'future') {
return Errors::unsupported(
'WordPress hat den Eintrag nicht als geplant gespeichert.',
'Eintrag in WordPress prüfen (z. B. Freigabe-Workflow eines anderen Plugins).'
);
}
// The status change is saved – failures from here on are logged and reported as warnings only.
$warnings = [];
FollowUp::run(
fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input),
$this->name() . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Planen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$warnings
);
$title = (string) $post->post_title;
$summary = sprintf('"%s" geplant für %s', $title, $raw)
. ($post->post_status === 'publish' ? ' (war veröffentlicht und ist bis dahin offline)' : '');
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok($summary . '.')
->changed((string) $postId, (string) $post->post_type, 'scheduled', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
->warnings($warnings)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('publish_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu veröffentlichen oder zu planen.');
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-trash: moves an entry to the trash – never a permanent delete. When the
* trash itself is disabled (EMPTY_TRASH_DAYS = 0), wp_trash_post() would delete the
* entry permanently (wp-includes/post.php: `if ( ! EMPTY_TRASH_DAYS ) return
* wp_delete_post( $post_id, true );`), so this tool refuses instead of calling it.
*/
final class PostTrashAbility implements Ability
{
/** @var callable(): bool */
private $trashEnabled;
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
?callable $trashEnabled = null
) {
$this->trashEnabled = $trashEnabled
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
}
public function name(): string
{
return 'lit/post-trash';
}
public function label(): string
{
return 'In den Papierkorb';
}
public function description(): string
{
return 'Legt einen Eintrag in den Papierkorb (nie endgültig löschen; wiederherstellbar in WordPress). '
. 'Erst nach Rückfrage beim Nutzer mit confirm: true aufrufen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'],
],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(sprintf('"%s" (ID %d) in den Papierkorb legen?', $title, $postId));
}
if ($post->post_status === 'trash') {
return ToolResult::ok(sprintf('"%s" liegt bereits im Papierkorb.', $title))
->rev(Rev::ofPost($post))
->toArray();
}
if (! ($this->trashEnabled)()) {
return Errors::unsupported(
'Der Papierkorb ist auf dieser Website deaktiviert (EMPTY_TRASH_DAYS = 0); '
. 'WordPress würde endgültig löschen. Nichts wurde geändert.',
'Endgültiges Löschen ist über LIT MCP nicht möglich; der Nutzer kann es in WordPress selbst tun.'
);
}
if (! wp_trash_post($postId)) {
return Errors::unsupported(
'WordPress hat das Verschieben in den Papierkorb abgelehnt.',
'Eintrag in WordPress prüfen (ein anderes Plugin kann das Verschieben verhindern).'
);
}
// The change is saved – a failure of the change log from here on is a warning only.
$summary = sprintf('"%s" in den Papierkorb gelegt', $title);
$warnings = [];
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok(sprintf('"%s" liegt im Papierkorb und ist dort wiederherstellbar.', $title))
->changed((string) $postId, (string) $post->post_type, 'trashed', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->warnings($warnings)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('delete_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu löschen.');
}
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-unpublish: withdraws a published, scheduled or private entry back to draft.
* An entry that is already not public (draft, pending, auto-draft) is a no-op.
*/
final class PostUnpublishAbility implements Ability
{
private const NOT_PUBLIC = ['draft', 'pending', 'auto-draft'];
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-unpublish';
}
public function label(): string
{
return 'Zurückziehen';
}
public function description(): string
{
return 'Setzt einen veröffentlichten, geplanten oder privaten Eintrag zurück auf Entwurf. '
. 'Nur auf ausdrücklichen Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
$editUrl = (string) get_edit_post_link($postId, 'raw');
if (in_array($post->post_status, self::NOT_PUBLIC, true)) {
return ToolResult::ok(sprintf('"%s" ist nicht veröffentlicht (Status: %s).', $title, $post->post_status))
->rev(Rev::ofPost($post))
->urls((string) get_preview_post_link($post), $editUrl)
->toArray();
}
$result = wp_update_post(['ID' => $postId, 'post_status' => 'draft'], true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Zurückziehen abgelehnt: ' . $result->get_error_message());
}
// The status change is saved – failures from here on are logged and reported as warnings only.
$warnings = [];
FollowUp::run(
fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input),
$this->name() . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Zurückziehen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$warnings
);
$summary = sprintf('"%s" zurückgezogen (vorher %s, jetzt Entwurf)', $title, $post->post_status);
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok($summary . '.')
->changed((string) $postId, (string) $post->post_type, 'unpublished', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->urls((string) get_preview_post_link($updated), $editUrl)
->warnings($warnings)
->toArray();
}
}
@@ -16,6 +16,10 @@ use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility;
use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility;
use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility;
use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
/**
* WordPress basics (spec §6): content, status, copies, revisions, menus, settings.
@@ -48,6 +52,10 @@ final class WordPressIntegration implements Integration
new ContentGetAbility($this->schema, $presenter),
new ContentCreateAbility($this->schema, $writer, $this->log),
new ContentUpdateAbility($this->schema, $writer, $this->log),
new PostPublishAbility($this->schema, $this->log),
new PostUnpublishAbility($this->schema, $this->log),
new PostScheduleAbility($this->schema, $this->log),
new PostTrashAbility($this->schema, $this->log),
];
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostPublishAbilityTest extends TestCase
{
private function ability(ChangeLog $log): PostPublishAbility
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostPublishAbility($schema, $log);
}
private function stubPost(array $fields): void
{
Functions\when('get_post')->justReturn(new WP_Post(array_merge(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team'], $fields)));
Functions\when('current_user_can')->justReturn(true);
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');
}
public function testPublishesADraft(): void
{
$this->stubPost(['post_status' => 'draft', 'post_date_gmt' => '0000-00-00 00:00:00']);
Functions\expect('wp_update_post')->once()->with(['ID' => 5, 'post_status' => 'publish'], true)->andReturn(5);
Functions\when('get_post_status')->justReturn('publish');
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/post-publish', 'page', '5', '"Team" veröffentlicht')->andReturn(9);
$result = $this->ability($log)->execute(['post_id' => 5]);
self::assertSame('published', $result['changed'][0]['action']);
self::assertSame(9, $result['log_id']);
self::assertSame('https://kunde.de/team/', $result['preview_url']);
}
public function testScheduledPostIsPublishedNowInsteadOfStayingFuture(): void
{
$this->stubPost(['post_status' => 'future', 'post_date_gmt' => '2099-01-01 10:00:00']);
Functions\when('current_time')->alias(static fn (string $type, $gmt = 0) => $gmt ? '2026-09-25 08:00:00' : '2026-09-25 10:00:00');
Functions\expect('wp_update_post')->once()->with([
'ID' => 5,
'post_status' => 'publish',
'post_date' => '2026-09-25 10:00:00',
'post_date_gmt' => '2026-09-25 08:00:00',
'edit_date' => true,
], true)->andReturn(5);
Functions\when('get_post_status')->justReturn('publish');
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(1);
$this->ability($log)->execute(['post_id' => 5]);
}
public function testAlreadyPublishedIsANoOp(): void
{
$this->stubPost(['post_status' => 'publish']);
Functions\expect('wp_update_post')->never();
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]);
self::assertSame([], $result['changed']);
self::assertStringContainsString('bereits veröffentlicht', $result['summary']);
}
public function testPermissionNeedsPublishRights(): void
{
$this->stubPost(['post_status' => 'draft']);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'publish_post');
self::assertSame('lit_forbidden', $this->ability(Mockery::mock(ChangeLog::class))->permission(['post_id' => 5])->get_error_code());
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use DateTimeZone;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostScheduleAbilityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_title' => 'News', 'post_status' => 'draft']));
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_timezone')->justReturn(new DateTimeZone('Europe/Berlin'));
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
}
private function ability(ChangeLog $log): PostScheduleAbility
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostScheduleAbility($schema, $log);
}
public function testSchedulesWithLocalAndGmtDates(): void
{
Functions\expect('wp_update_post')->once()->with([
'ID' => 5,
'post_status' => 'future',
'post_date' => '2099-06-01 09:30:00',
'post_date_gmt' => '2099-06-01 07:30:00',
'edit_date' => true,
], true)->andReturn(5);
Functions\when('get_post_status')->justReturn('future');
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andReturn(4);
$result = $this->ability($log)->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']);
self::assertSame('scheduled', $result['changed'][0]['action']);
self::assertStringContainsString('2099-06-01 09:30', $result['summary']);
}
public function testPastAndImpossibleDatesAreRejected(): void
{
Functions\expect('wp_update_post')->never();
$ability = $this->ability(Mockery::mock(ChangeLog::class));
$past = $ability->execute(['post_id' => 5, 'date' => '2020-01-01 10:00']);
$impossible = $ability->execute(['post_id' => 5, 'date' => '2099-13-45 10:00']);
self::assertSame('lit_validation_failed', $past->get_error_code());
self::assertStringContainsString('Zukunft', $past->get_error_message());
self::assertSame('lit_validation_failed', $impossible->get_error_code());
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostTrashAbilityTest extends TestCase
{
private function ability(ChangeLog $log): PostTrashAbility
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Alt', 'post_status' => 'publish']));
Functions\when('current_user_can')->justReturn(true);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostTrashAbility($schema, $log);
}
public function testRequiresConfirmation(): void
{
Functions\expect('wp_trash_post')->never();
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]);
self::assertSame('lit_confirm_required', $result->get_error_code());
}
public function testMovesToTrashNeverDeletes(): void
{
Functions\expect('wp_trash_post')->once()->with(5)->andReturn(new WP_Post(['ID' => 5]));
Functions\expect('wp_delete_post')->never();
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/post-trash', 'page', '5', '"Alt" in den Papierkorb gelegt')->andReturn(2);
$result = $this->ability($log)->execute(['post_id' => 5, 'confirm' => true]);
self::assertSame('trashed', $result['changed'][0]['action']);
self::assertStringContainsString('Papierkorb', $result['summary']);
}
public function testRefusesWhenTheTrashIsDisabledInsteadOfDeleting(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']));
Functions\when('current_user_can')->justReturn(true);
Functions\expect('wp_trash_post')->never();
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$ability = new PostTrashAbility($schema, Mockery::mock(ChangeLog::class), static fn (): bool => false);
$result = $ability->execute(['post_id' => 5, 'confirm' => true]);
self::assertSame('lit_unsupported', $result->get_error_code());
}
public function testIsDestructiveAndIndirect(): void
{
$ability = $this->ability(Mockery::mock(ChangeLog::class));
self::assertTrue($ability->annotations()['destructive']);
self::assertFalse($ability->isDirect());
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostUnpublishAbilityTest extends TestCase
{
private function ability(ChangeLog $log, string $status): PostUnpublishAbility
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => $status]));
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return new PostUnpublishAbility($schema, $log);
}
public function testPublishedPostBecomesDraft(): void
{
Functions\expect('wp_update_post')->once()->with(['ID' => 5, 'post_status' => 'draft'], true)->andReturn(5);
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andReturn(3);
$result = $this->ability($log, 'publish')->execute(['post_id' => 5]);
self::assertSame('unpublished', $result['changed'][0]['action']);
}
public function testDraftIsANoOp(): void
{
Functions\expect('wp_update_post')->never();
$result = $this->ability(Mockery::mock(ChangeLog::class), 'draft')->execute(['post_id' => 5]);
self::assertSame([], $result['changed']);
}
}
@@ -20,6 +20,10 @@ final class WordPressIntegrationTest extends TestCase
'lit/content-get',
'lit/content-create',
'lit/content-update',
'lit/post-publish',
'lit/post-unpublish',
'lit/post-schedule',
'lit/post-trash',
];
public function testProvidesTheWordPressAbilitiesInOrder(): void