feat: batch media metadata updates and delete with usage check

lit/media-update validates the whole batch first (edit_post, lengths, optional
modified lock) and restores already written items when a write fails midway.
lit/media-delete requires confirm, refuses used media without force and
reports refused deletes as write_failed.

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-26 02:49:41 +02:00
co-authored by Claude Opus 5.5
parent 56f561c436
commit 931ddeb137
9 changed files with 1145 additions and 1 deletions
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
/**
* lit/media-delete: deletes one attachment after confirmation. WordPress has no media trash by
* default (MEDIA_TRASH false): wp_delete_attachment($id, false) then deletes the attachment and
* all its files permanently. Media that is used – certainly or possibly – needs force: true.
* Trashed attachments are refused (MediaGuard): deleting them would always be permanent.
*/
final class MediaDeleteAbility implements Ability
{
/** @var callable(): bool Whether wp_delete_attachment($id, false) moves to the trash. */
private $mediaTrash;
public function __construct(
private MediaUsageFinder $finder,
private ChangeLog $log,
?callable $mediaTrash = null
) {
$this->mediaTrash = $mediaTrash ?? static fn (): bool => defined('MEDIA_TRASH') && MEDIA_TRASH
&& defined('EMPTY_TRASH_DAYS') && EMPTY_TRASH_DAYS;
}
public function name(): string
{
return 'lit/media-delete';
}
public function label(): string
{
return 'Medium löschen';
}
public function description(): string
{
return 'Löscht ein Medium samt Datei und allen Bildgrößen – ohne aktivierten Medien-Papierkorb '
. '(WordPress-Standard) endgültig, das lässt sich nicht rückgängig machen. Ohne confirm: true kommt '
. 'eine Rückfrage (confirm_required) mit den gefundenen Verwendungen; erst nach Zustimmung des Nutzers '
. 'mit confirm: true erneut aufrufen. Wird das Medium verwendet (auch nur "möglicherweise"), lehnt das '
. 'Tool mit in_use ab, außer force: true. Medien im Papierkorb werden nicht gelöscht. '
. 'Die Verwendungsprüfung findet NICHT: Term-Meta, Benutzer-Meta, Pods-Tabellen (pods_*, podsrel), '
. 'ACF-Felder (außer als Bildfeld im Schema mit einer ID pro Meta-Eintrag), serialisierte oder '
. 'kommagetrennte ID-Listen in Meta-Feldern und Optionen anderer Plugins – im Zweifel den Nutzer fragen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'minimum' => 1],
'confirm' => [
'type' => 'boolean',
'description' => 'Erst nach Rückfrage beim Nutzer true setzen.',
],
'force' => ['type' => 'boolean', 'description' => 'Trotz gefundener Verwendungen löschen.'],
],
'required' => ['id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(
['usages' => ['type' => 'array', 'items' => MediaUsageFinder::usageSchema()]],
['usages']
);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post');
return $attachment instanceof WP_Error ? $attachment : true;
}
public function execute(array $input): mixed
{
$attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post');
if ($attachment instanceof WP_Error) {
return $attachment;
}
$id = (int) $attachment->ID;
$title = (string) $attachment->post_title;
$trash = ($this->mediaTrash)();
// Same search as MediaUsageFinder::isUsed(): certain and possible usages both count.
$usages = $this->finder->usages($id);
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired($this->question($id, $title, $trash, $usages), ['usages' => $usages]);
}
if ($usages !== [] && ($input['force'] ?? false) !== true) {
return Errors::inUse(
sprintf(
'Medium "%s" wird noch verwendet (%d Stelle(n)); nichts wurde gelöscht.',
$title,
count($usages)
),
$usages
);
}
if (! wp_delete_attachment($id, false)) {
return Errors::writeFailed(sprintf('WordPress hat das Löschen von Medium %d abgelehnt.', $id));
}
$warnings = [];
$summary = sprintf('Medium "%s" %s', $title, $trash ? 'in den Papierkorb gelegt' : 'gelöscht');
$logId = FollowUp::run(
fn (): ?int => $this->log->record($this->name(), 'attachment', (string) $id, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; das Medium ist trotzdem '
. ($trash ? 'im Papierkorb.' : 'gelöscht.'),
$warnings
);
return ToolResult::ok($summary . '.')
->changed((string) $id, 'attachment', $trash ? 'trashed' : 'deleted', $title)
->logId(is_int($logId) ? $logId : null)
->warnings($warnings)
->with('usages', $usages)
->toArray();
}
/** @param list<array<string, mixed>> $usages */
private function question(int $id, string $title, bool $trash, array $usages): string
{
$message = sprintf(
'Medium "%s" (ID %d) %s?',
$title,
$id,
$trash
? 'in den Papierkorb legen'
: 'endgültig löschen – samt Datei und allen Bildgrößen, nicht rückgängig zu machen'
);
if ($usages === []) {
return $message . ' Keine Verwendung gefunden (die Suche deckt nicht alle Speicherorte ab).';
}
$used = ' Es wird an %d Stelle(n) verwendet (siehe usages); löschen nur mit force: true.';
return $message . sprintf($used, count($usages));
}
}
+3 -1
View File
@@ -11,7 +11,7 @@ use LIT\MCP\Limits;
/** /**
* Media library tools (spec §6.3). Always available. $log and $limits serve the write * Media library tools (spec §6.3). Always available. $log and $limits serve the write
* tools (upload now, update and delete to follow). * tools (upload, update, delete).
*/ */
final class MediaIntegration implements Integration final class MediaIntegration implements Integration
{ {
@@ -43,6 +43,8 @@ final class MediaIntegration implements Integration
new MediaUploadFromUrlAbility($importer, $uploads), new MediaUploadFromUrlAbility($importer, $uploads),
new MediaUploadAbility($importer, $uploads, $this->limits), new MediaUploadAbility($importer, $uploads, $this->limits),
new MediaDownloadAbility($this->limits), new MediaDownloadAbility($this->limits),
new MediaUpdateAbility($this->log),
new MediaDeleteAbility($finder, $this->log),
]; ];
} }
} }
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use RuntimeException;
use WP_Error;
use WP_Post;
/**
* Writes the editable metadata of an attachment (alt text, title, caption, description) and
* restores it from a snapshot taken before (lit/media-update rolls a batch back with it).
* Every failure is a RuntimeException with an internal message for the PHP error log.
*/
final class MediaMetaWriter
{
public const ALT_KEY = '_wp_attachment_image_alt';
/** Input key => posts column. */
public const COLUMNS = ['title' => 'post_title', 'caption' => 'post_excerpt', 'description' => 'post_content'];
/**
* The current values of everything $changes will write.
*
* @param array<string, string> $changes Sanitized values by input key (alt, title, caption, description).
* @return array{id: int, columns: array<string, string>, alt_changed: bool, alt: ?string}
* alt null = there was no alt text (it is removed again on restore).
*/
public function snapshot(WP_Post $attachment, array $changes): array
{
$id = (int) $attachment->ID;
$columns = [];
foreach (self::columns($changes) as $column => $value) {
$columns[$column] = (string) $attachment->{$column};
}
$altChanged = array_key_exists('alt', $changes);
$alt = $altChanged && metadata_exists('post', $id, self::ALT_KEY)
? (string) get_post_meta($id, self::ALT_KEY, true)
: null;
return ['id' => $id, 'columns' => $columns, 'alt_changed' => $altChanged, 'alt' => $alt];
}
/**
* @param array<string, string> $changes Sanitized values by input key.
* @param bool $touched Set to true as soon as something may have been written.
* @throws RuntimeException
*/
public function apply(int $id, array $changes, bool &$touched): void
{
$columns = self::columns($changes);
if ($columns !== []) {
// Set before the call: a throwing save_post hook runs after the row was written.
$touched = true;
$result = wp_update_post(wp_slash(array_merge($columns, ['ID' => $id])), true);
if ($result instanceof WP_Error || ! $result) {
// wp_update_post() reports errors before it writes anything.
$touched = false;
throw new RuntimeException(sprintf(
'Attachment %d not updated: %s',
$id,
$result instanceof WP_Error ? $result->get_error_message() : 'unknown error'
));
}
}
if (array_key_exists('alt', $changes)) {
$touched = true;
$this->saveAlt($id, $changes['alt']);
}
}
/**
* @param array{id: int, columns: array<string, string>, alt_changed: bool, alt: ?string} $snapshot
* @throws RuntimeException
*/
public function restore(array $snapshot): void
{
$id = $snapshot['id'];
if ($snapshot['columns'] !== []) {
$result = wp_update_post(wp_slash(array_merge($snapshot['columns'], ['ID' => $id])), true);
if ($result instanceof WP_Error || ! $result) {
throw new RuntimeException(sprintf(
'Attachment %d not restored: %s',
$id,
$result instanceof WP_Error ? $result->get_error_message() : 'unknown error'
));
}
}
if (! $snapshot['alt_changed']) {
return;
}
if ($snapshot['alt'] !== null) {
$this->saveAlt($id, $snapshot['alt']);
} elseif (! delete_post_meta($id, self::ALT_KEY) && metadata_exists('post', $id, self::ALT_KEY)) {
throw new RuntimeException(sprintf('Alt text of attachment %d not removed again.', $id));
}
}
/**
* @param array<string, string> $changes
* @return array<string, string> Column => value.
*/
private static function columns(array $changes): array
{
$columns = [];
foreach (self::COLUMNS as $key => $column) {
if (array_key_exists($key, $changes)) {
$columns[$column] = $changes[$key];
}
}
return $columns;
}
/** @throws RuntimeException */
private function saveAlt(int $id, string $alt): void
{
// update_post_meta() is also false when the value is already stored.
$stored = update_post_meta($id, self::ALT_KEY, wp_slash($alt)) !== false
|| (string) get_post_meta($id, self::ALT_KEY, true) === $alt;
if (! $stored) {
throw new RuntimeException(sprintf('Alt text of attachment %d not saved.', $id));
}
}
}
@@ -29,6 +29,8 @@ class MediaPresenter
'height' => isset($meta['height']) ? (int) $meta['height'] : null, 'height' => isset($meta['height']) ? (int) $meta['height'] : null,
'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null, 'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null,
'date' => (string) $attachment->post_date, 'date' => (string) $attachment->post_date,
// Optimistic lock for lit/media-update (post_modified_gmt, changes with every row write).
'modified' => (string) $attachment->post_modified_gmt,
]; ];
} }
@@ -77,6 +79,7 @@ class MediaPresenter
'height' => ['type' => ['integer', 'null']], 'height' => ['type' => ['integer', 'null']],
'filesize' => ['type' => ['integer', 'null']], 'filesize' => ['type' => ['integer', 'null']],
'date' => ['type' => 'string'], 'date' => ['type' => 'string'],
'modified' => ['type' => 'string'],
], ],
'required' => ['id', 'title', 'mime_type', 'url', 'alt'], 'required' => ['id', 'title', 'mime_type', 'url', 'alt'],
]; ];
@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use Throwable;
use WP_Error;
use WP_Post;
/**
* lit/media-update: metadata of up to 50 attachments in one call, all or nothing.
* Every item is validated first (attachment, edit_post, lengths, optional `modified` lock);
* one invalid item refuses the whole batch. If a write fails midway, the items already
* changed are restored (write_failed) – or, if that fails too, partial_write names them.
*
* The `modified` lock compares post_modified_gmt, which WordPress bumps on every write of the
* posts row (title, caption, description) but not on a pure alt-text (post meta) change.
*/
final class MediaUpdateAbility implements Ability
{
public const MAX_ITEMS = 50;
/** Max. characters per field (before sanitizing). */
private const MAX_LENGTH = ['alt' => 500, 'title' => 255, 'caption' => 2000, 'description' => 20000];
private const REASON = 'WordPress hat die Metadaten eines Mediums nicht gespeichert.';
private const REV_HINT = 'Die genannten Medien neu lesen (lit/media-get oder lit-media-search) und mit dem '
. 'aktuellen modified erneut senden.';
public function __construct(private ChangeLog $log, private MediaMetaWriter $writer = new MediaMetaWriter())
{
}
public function name(): string
{
return 'lit/media-update';
}
public function label(): string
{
return 'Medien-Metadaten ändern';
}
public function description(): string
{
return 'Ändert Alt-Text, Titel, Bildunterschrift und Beschreibung von bis zu 50 Medien in einem Aufruf – '
. 'alles oder nichts: Ist ein Eintrag ungültig (unbekannte ID, keine Berechtigung, Papierkorb, zu lang, '
. 'nichts zu ändern, veraltetes modified), wird nichts gespeichert und failures nennt jeden fehlerhaften '
. 'Eintrag. Optional pro Eintrag modified (Wert aus lit/media-get oder lit-media-search) mitsenden: '
. 'Wurde das Medium seitdem geändert, lehnt das Tool mit rev_mismatch ab. modified ändert sich bei '
. 'Titel, Bildunterschrift und Beschreibung, nicht bei einer reinen Alt-Text-Änderung. '
. 'Das Ergebnis meldet jeden Eintrag (results, mit neuem modified).';
}
public function inputSchema(): array
{
$max = self::MAX_LENGTH;
$text = static fn (string $description, string $key): array => [
'type' => 'string',
'maxLength' => $max[$key],
'description' => $description,
];
return [
'type' => 'object',
'properties' => [
'items' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => self::MAX_ITEMS,
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'minimum' => 1],
'alt' => $text('Alt-Text (reiner Text).', 'alt'),
'title' => $text('Titel in der Mediathek (reiner Text).', 'title'),
'caption' => $text('Bildunterschrift (einfaches HTML erlaubt).', 'caption'),
'description' => $text('Beschreibung (einfaches HTML erlaubt).', 'description'),
'modified' => [
'type' => 'string',
'description' => 'Optional: modified aus lit/media-get bzw. lit-media-search als '
. 'Schutz vor parallelen Änderungen.',
],
],
'required' => ['id'],
'additionalProperties' => false,
],
],
],
'required' => ['items'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema([
'results' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'ok' => ['type' => 'boolean'],
'error' => ['type' => ['string', 'null']],
'log_id' => ['type' => ['integer', 'null']],
'modified' => ['type' => ['string', 'null']],
],
'required' => ['id', 'ok', 'error', 'log_id', 'modified'],
],
],
], ['results']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
/** edit_post per attachment is checked in execute() for every item. */
public function permission(array $input): bool|WP_Error
{
return current_user_can('upload_files')
? true
: Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).');
}
public function execute(array $input): mixed
{
$plan = $this->plan($input['items'] ?? null);
if ($plan instanceof WP_Error) {
return $plan;
}
$this->write($plan);
return $this->result($plan);
}
/**
* Validates every item; nothing is written yet.
*
* @return list<array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
* columns: array<string, string>, alt_changed: bool, alt: ?string}}>|WP_Error
*/
private function plan(mixed $items): array|WP_Error
{
if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) {
return Errors::invalidInput(
sprintf('items muss eine Liste mit 1 bis %d Einträgen sein.', self::MAX_ITEMS),
['items' => sprintf('1–%d Einträge', self::MAX_ITEMS)]
);
}
$plan = [];
$failures = [];
$seen = [];
foreach ($items as $index => $item) {
$entry = $this->validate(is_array($item) ? $item : [], $seen);
if ($entry instanceof WP_Error) {
$failures[] = self::failure($index, is_array($item) ? $item : [], $entry);
continue;
}
$plan[] = $entry;
}
return $failures === [] ? $plan : self::refusal($failures, count($items));
}
/**
* @param array<string, mixed> $item
* @param array<int, true> $seen IDs of the items before.
* @return array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
* columns: array<string, string>, alt_changed: bool, alt: ?string}}|WP_Error
*/
private function validate(array $item, array &$seen): array|WP_Error
{
$attachment = MediaGuard::attachment($item['id'] ?? null);
if ($attachment instanceof WP_Error) {
return $attachment;
}
$id = (int) $attachment->ID;
$duplicate = isset($seen[$id]);
$seen[$id] = true;
$changes = [];
foreach (self::MAX_LENGTH as $key => $max) {
if (! array_key_exists($key, $item)) {
continue;
}
if (! is_string($item[$key]) || mb_strlen($item[$key]) > $max) {
return Errors::invalidInput(sprintf('%s: Text mit höchstens %d Zeichen erwartet.', $key, $max));
}
$changes[$key] = in_array($key, ['alt', 'title'], true)
? sanitize_text_field($item[$key])
: wp_kses_post($item[$key]);
}
if ($changes === []) {
return Errors::invalidInput('Nichts zu ändern: alt, title, caption oder description angeben.');
}
if ($duplicate) {
return Errors::invalidInput(sprintf('Medium %d steht mehrfach in items.', $id));
}
if (array_key_exists('modified', $item) && $item['modified'] !== (string) $attachment->post_modified_gmt) {
return self::revMismatch($id);
}
return [
'attachment' => $attachment,
'changes' => $changes,
'snapshot' => $this->writer->snapshot($attachment, $changes),
];
}
/**
* @param list<array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
* columns: array<string, string>, alt_changed: bool, alt: ?string}}> $plan
* @throws WriteFailed When an item could not be saved.
*/
private function write(array $plan): void
{
$written = [];
foreach ($plan as $entry) {
$touched = false;
try {
$this->writer->apply((int) $entry['attachment']->ID, $entry['changes'], $touched);
} catch (Throwable $e) {
if ($touched) {
$written[] = $entry;
}
$this->rollBack($written, $e);
}
$written[] = $entry;
}
}
/**
* Restores the items written so far, newest first.
*
* @param list<array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
* columns: array<string, string>, alt_changed: bool, alt: ?string}}> $written
* @throws WriteFailed Always – rolledBack tells whether everything was restored.
*/
private function rollBack(array $written, Throwable $cause): never
{
$unrestored = [];
$failures = [];
foreach (array_reverse($written) as $entry) {
try {
$this->writer->restore($entry['snapshot']);
} catch (Throwable $e) {
$unrestored[] = $entry['attachment'];
$failures[] = $e->getMessage();
}
}
$detail = 'Media update failed: ' . $cause->getMessage();
if ($unrestored === []) {
throw new WriteFailed(self::REASON, true, null, null, $cause, $detail);
}
$ids = [];
foreach (array_reverse($unrestored) as $attachment) {
$ids[] = (int) $attachment->ID;
$this->log->recordPartial(
$this->name(),
'attachment',
(string) $attachment->ID,
sprintf('Metadaten von "%s": Änderung nur teilweise zurückgenommen', $attachment->post_title)
);
}
throw new WriteFailed(
self::REASON,
false,
null,
null,
$cause,
$detail . '; rollback incomplete: ' . implode('; ', $failures),
sprintf('Möglicherweise geändert: Medien %s (siehe changed_ids).', implode(', ', $ids)),
'Den Aufruf nicht einfach wiederholen. Die unter changed_ids genannten Medien mit lit/media-get prüfen, '
. 'die Metadaten ggf. korrigieren und dem Nutzer Bescheid geben.',
['changed_ids' => $ids]
);
}
/**
* Everything is saved – change log and reading back are follow-up steps (warnings only).
*
* @param list<array{attachment: WP_Post, changes: array<string, string>, snapshot: array{id: int,
* columns: array<string, string>, alt_changed: bool, alt: ?string}}> $plan
* @return array<string, mixed>
*/
private function result(array $plan): array
{
$warnings = [];
$results = [];
$result = ToolResult::ok(sprintf('%1$d von %1$d Medien aktualisiert.', count($plan)));
$lastLogId = null;
foreach ($plan as $entry) {
$attachment = $entry['attachment'];
$id = (int) $attachment->ID;
$summary = sprintf(
'Metadaten von "%s" geändert: %s',
$attachment->post_title,
implode(', ', array_keys($entry['changes']))
);
$logId = FollowUp::run(
fn (): ?int => $this->log->record($this->name(), 'attachment', (string) $id, $summary),
$this->name() . ' change log',
sprintf(
'Änderungsprotokoll für Medium %d konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$id
),
$warnings
);
$logId = is_int($logId) ? $logId : null;
$lastLogId = $logId ?? $lastLogId;
$current = get_post($id);
$results[] = [
'id' => $id,
'ok' => true,
'error' => null,
'log_id' => $logId,
'modified' => $current instanceof WP_Post ? (string) $current->post_modified_gmt : null,
];
$result->changed(
(string) $id,
'attachment',
'updated',
$entry['changes']['title'] ?? (string) $attachment->post_title
);
}
return $result->logId($lastLogId)->warnings($warnings)->with('results', $results)->toArray();
}
/**
* @param array<string, mixed> $item
* @return array{index: int, id: ?int, code: string, error: string}
*/
private static function failure(int $index, array $item, WP_Error $error): array
{
$payload = json_decode($error->get_error_message(), true);
$id = $item['id'] ?? null;
return [
'index' => $index,
'id' => is_int($id) ? $id : null,
'code' => (string) preg_replace('/^lit_/', '', (string) $error->get_error_code()),
'error' => is_array($payload) ? (string) ($payload['message'] ?? '') : $error->get_error_message(),
];
}
/**
* One error for the whole batch: the common code of all failures, else validation_failed.
*
* @param non-empty-list<array{index: int, id: ?int, code: string, error: string}> $failures
*/
private static function refusal(array $failures, int $total): WP_Error
{
$codes = array_unique(array_column($failures, 'code'));
$message = sprintf(
'%d von %d Einträgen fehlerhaft (siehe failures); nichts wurde gespeichert.',
count($failures),
$total
);
return match (count($codes) === 1 ? $codes[0] : 'mixed') {
'forbidden' => Errors::make('forbidden', $message, 'Nur Medien ändern, die das verbundene Konto '
. 'bearbeiten darf; die übrigen Einträge erneut senden.', 403, ['failures' => $failures]),
'not_found' => Errors::make('not_found', $message, 'IDs prüfen (lit-media-search) und die Einträge '
. 'korrigiert erneut senden.', 404, ['failures' => $failures]),
'rev_mismatch' => Errors::make('rev_mismatch', $message, self::REV_HINT, 409, ['failures' => $failures]),
default => Errors::make('validation_failed', $message, 'Die unter failures genannten Einträge '
. 'korrigieren und den ganzen Stapel erneut senden.', 400, ['failures' => $failures]),
};
}
private static function revMismatch(int $id): WP_Error
{
return Errors::make(
'rev_mismatch',
sprintf('Medium %d wurde seit dem letzten Lesen geändert.', $id),
self::REV_HINT,
409
);
}
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Media\MediaDeleteAbility;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;
final class MediaDeleteAbilityTest extends TestCase
{
private const USAGE = [['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher']];
private const POSSIBLE = [['post_id' => 7, 'title' => 'Blog', 'post_type' => 'post', 'where' => 'content', 'certainty' => 'möglicherweise']];
protected function setUp(): void
{
parent::setUp();
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Foto']));
Functions\when('current_user_can')->justReturn(true);
}
private function ability(array $usages, ChangeLog $log, bool $trash = false): MediaDeleteAbility
{
$finder = Mockery::mock(MediaUsageFinder::class);
$finder->allows('usages')->with(33)->andReturn($usages);
return new MediaDeleteAbility($finder, $log, static fn (): bool => $trash);
}
public function testConfirmationIsRequiredAndMentionsUsages(): void
{
Functions\expect('wp_delete_attachment')->never();
$result = $this->ability(self::USAGE, Mockery::mock(ChangeLog::class))->execute(['id' => 33]);
self::assertSame('lit_confirm_required', $result->get_error_code());
self::assertStringContainsString('endgültig', $result->get_error_message());
self::assertStringContainsString('1 Stelle', $result->get_error_message());
self::assertSame(self::USAGE, $result->get_error_data()['usages']);
}
public function testConfirmationMentionsTheTrashWhenMediaTrashIsOn(): void
{
$result = $this->ability([], Mockery::mock(ChangeLog::class), true)->execute(['id' => 33, 'confirm' => false]);
self::assertSame('lit_confirm_required', $result->get_error_code());
self::assertStringContainsString('Papierkorb', $result->get_error_message());
}
public function testUsedMediaNeedsForce(): void
{
Functions\expect('wp_delete_attachment')->never();
$result = $this->ability(self::USAGE, Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]);
self::assertSame('lit_in_use', $result->get_error_code());
self::assertSame(self::USAGE, $result->get_error_data()['usages']);
}
public function testPossibleUsageAlsoNeedsForce(): void
{
Functions\expect('wp_delete_attachment')->never();
$result = $this->ability(self::POSSIBLE, Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]);
self::assertSame('lit_in_use', $result->get_error_code());
}
public function testForceDeletesUsedMedia(): void
{
Functions\expect('wp_delete_attachment')->once()->with(33, false)->andReturn(new WP_Post(['ID' => 33]));
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/media-delete', 'attachment', '33', 'Medium "Foto" gelöscht')->andReturn(50);
$result = $this->ability(self::USAGE, $log)->execute(['id' => 33, 'confirm' => true, 'force' => true]);
self::assertSame(['id' => '33', 'type' => 'attachment', 'action' => 'deleted', 'label' => 'Foto'], $result['changed'][0]);
self::assertSame(50, $result['log_id']);
self::assertSame(self::USAGE, $result['usages']);
}
public function testUnusedMediaGoesToTheTrashWhenMediaTrashIsOn(): void
{
Functions\expect('wp_delete_attachment')->once()->with(33, false)->andReturn(new WP_Post(['ID' => 33]));
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->with('lit/media-delete', 'attachment', '33', 'Medium "Foto" in den Papierkorb gelegt')->andReturn(51);
$result = $this->ability([], $log, true)->execute(['id' => 33, 'confirm' => true]);
self::assertSame('trashed', $result['changed'][0]['action']);
}
public function testRefusedDeleteIsAWriteFailure(): void
{
Functions\expect('wp_delete_attachment')->once()->andReturn(false);
$result = $this->ability([], Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]);
self::assertSame('lit_write_failed', $result->get_error_code());
}
public function testChangeLogFailureIsOnlyAWarning(): void
{
Functions\expect('wp_delete_attachment')->once()->andReturn(new WP_Post(['ID' => 33]));
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andThrow(new RuntimeException('db down'));
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $file);
try {
$result = $this->ability([], $log)->execute(['id' => 33, 'confirm' => true]);
} finally {
ini_set('error_log', (string) $previous);
unlink($file);
}
self::assertSame('deleted', $result['changed'][0]['action']);
self::assertNull($result['log_id']);
self::assertCount(1, $result['warnings']);
}
public function testTrashedAttachmentIsRefused(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'trash']));
Functions\expect('wp_delete_attachment')->never();
$ability = $this->ability([], Mockery::mock(ChangeLog::class));
self::assertSame('lit_validation_failed', $ability->permission(['id' => 33])->get_error_code());
self::assertSame('lit_validation_failed', $ability->execute(['id' => 33, 'confirm' => true])->get_error_code());
}
public function testChecksDeletePostOnTheAttachment(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => ! ($cap === 'delete_post' && $id === 33));
$result = $this->ability([], Mockery::mock(ChangeLog::class))->permission(['id' => 33]);
self::assertSame('lit_forbidden', $result->get_error_code());
}
public function testIsDestructiveAndDescribesWhatTheUsageCheckMisses(): void
{
$ability = $this->ability([], Mockery::mock(ChangeLog::class));
self::assertSame(['readonly' => false, 'destructive' => true, 'idempotent' => false], $ability->annotations());
self::assertFalse($ability->isDirect());
foreach (['endgültig', 'Term-Meta', 'Benutzer-Meta', 'Pods', 'ACF', 'kommagetrennt', 'Optionen anderer Plugins'] as $needle) {
self::assertStringContainsString($needle, $ability->description());
}
}
}
@@ -22,6 +22,8 @@ final class MediaIntegrationTest extends TestCase
'lit/media-upload-from-url', 'lit/media-upload-from-url',
'lit/media-upload', 'lit/media-upload',
'lit/media-download', 'lit/media-download',
'lit/media-update',
'lit/media-delete',
]; ];
public function testProvidesTheMediaAbilities(): void public function testProvidesTheMediaAbilities(): void
@@ -45,6 +45,7 @@ final class MediaPresenterTest extends TestCase
self::assertSame('Porträt', $summary['alt']); self::assertSame('Porträt', $summary['alt']);
self::assertSame(1600, $summary['width']); self::assertSame(1600, $summary['width']);
self::assertSame(123456, $summary['filesize']); self::assertSame(123456, $summary['filesize']);
self::assertSame('2026-09-25 08:00:00', $summary['modified']);
} }
public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void
@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Media\MediaUpdateAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Error;
use WP_Post;
final class MediaUpdateAbilityTest extends TestCase
{
/** @var list<array<string, mixed>> */
private array $postUpdates = [];
/** @var list<array{int, string}> */
private array $altUpdates = [];
protected function setUp(): void
{
parent::setUp();
$this->postUpdates = [];
$this->altUpdates = [];
$posts = [
33 => new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Foto',
'post_excerpt' => 'Alt-Unterschrift', 'post_modified_gmt' => '2026-09-25 08:00:00']),
34 => new WP_Post(['ID' => 34, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Logo',
'post_modified_gmt' => '2026-09-25 09:00:00']),
35 => new WP_Post(['ID' => 35, 'post_type' => 'attachment', 'post_status' => 'trash', 'post_title' => 'Alt']),
5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Seite']),
];
Functions\when('get_post')->alias(static fn ($id) => $posts[(int) $id] ?? null);
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_kses_post')->alias(static fn ($value): string => strip_tags((string) $value, '<a><em><strong>'));
Functions\when('metadata_exists')->justReturn(true);
Functions\when('get_post_meta')->justReturn('alter Alt-Text');
Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) {
$this->postUpdates[] = $data;
return (int) $data['ID'];
});
Functions\when('update_post_meta')->alias(function (int $id, string $key, $value) {
$this->altUpdates[] = [$id, $value];
return true;
});
}
private function log(int ...$ids): ChangeLog
{
$log = Mockery::mock(ChangeLog::class);
if ($ids !== []) {
$log->expects('record')->times(count($ids))->andReturn(...$ids);
}
return $log;
}
public function testUpdatesEveryItemAndReportsPerItem(): void
{
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')
->with('lit/media-update', 'attachment', '33', 'Metadaten von "Foto" geändert: alt, caption')
->andReturn(41);
$log->expects('record')
->with('lit/media-update', 'attachment', '34', 'Metadaten von "Logo" geändert: title')
->andReturn(42);
$result = (new MediaUpdateAbility($log))->execute(['items' => [
['id' => 33, 'alt' => 'Team vor dem Haus', 'caption' => 'Unser <script>x</script>Team'],
['id' => 34, 'title' => '<b>Firmen</b>logo', 'modified' => '2026-09-25 09:00:00'],
]]);
self::assertSame([['post_excerpt' => 'Unser xTeam', 'ID' => 33], ['post_title' => 'Firmenlogo', 'ID' => 34]], $this->postUpdates);
self::assertSame([[33, 'Team vor dem Haus']], $this->altUpdates);
self::assertSame(['id' => 33, 'ok' => true, 'error' => null, 'log_id' => 41, 'modified' => '2026-09-25 08:00:00'], $result['results'][0]);
self::assertSame(42, $result['results'][1]['log_id']);
self::assertSame(['id' => '34', 'type' => 'attachment', 'action' => 'updated', 'label' => 'Firmenlogo'], $result['changed'][1]);
self::assertSame('2 von 2 Medien aktualisiert.', $result['summary']);
}
public function testAnInvalidItemRefusesTheWholeBatchAndNamesEveryFailure(): void
{
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33, 'alt' => 'ok'],
['id' => 99, 'alt' => 'x'],
['id' => 5, 'alt' => 'x'],
['id' => 35, 'alt' => 'x'],
]]);
self::assertInstanceOf(WP_Error::class, $result);
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertStringContainsString('3 von 4', $result->get_error_message());
self::assertStringContainsString('nichts wurde gespeichert', $result->get_error_message());
$failures = $result->get_error_data()['failures'];
self::assertSame([1, 2, 3], array_column($failures, 'index'));
self::assertSame(['not_found', 'not_found', 'validation_failed'], array_column($failures, 'code'));
self::assertStringContainsString('nicht gefunden', $failures[0]['error']);
self::assertStringContainsString('Papierkorb', $failures[2]['error']);
self::assertSame([], $this->postUpdates);
self::assertSame([], $this->altUpdates);
}
public function testFailuresOfOneKindKeepTheirCode(): void
{
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => 99, 'alt' => 'x']]]);
self::assertSame('lit_not_found', $result->get_error_code());
}
public function testRejectsEmptyDuplicateAndOverlongItems(): void
{
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33],
['id' => 34, 'alt' => str_repeat('a', 501)],
['id' => 34, 'title' => 'doppelt'],
['id' => 33, 'caption' => ['kein', 'Text']],
]]);
$failures = $result->get_error_data()['failures'];
self::assertSame([0, 1, 2, 3], array_column($failures, 'index'));
self::assertStringContainsString('Nichts zu ändern', $failures[0]['error']);
self::assertStringContainsString('alt', $failures[1]['error']);
self::assertStringContainsString('mehrfach', $failures[2]['error']);
self::assertStringContainsString('caption', $failures[3]['error']);
self::assertSame([], $this->postUpdates);
}
public function testStaleModifiedRefusesTheBatchWithRevMismatch(): void
{
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33, 'alt' => 'neu', 'modified' => '2026-09-25 08:00:00'],
['id' => 34, 'alt' => 'neu', 'modified' => '2026-09-24 07:00:00'],
]]);
self::assertSame('lit_rev_mismatch', $result->get_error_code());
self::assertSame(1, $result->get_error_data()['failures'][0]['index']);
self::assertStringContainsString('lit/media-get', $result->get_error_data()['hint']);
self::assertSame([], $this->altUpdates);
}
public function testAFailedWriteRestoresTheItemsAlreadyChanged(): void
{
Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) {
$this->postUpdates[] = $data;
return $data['ID'] === 34 && ($data['post_title'] ?? '') === 'Neu'
? new WP_Error('db', 'Datenbankfehler')
: (int) $data['ID'];
});
try {
(new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33, 'alt' => 'neu', 'caption' => 'neu'],
['id' => 34, 'title' => 'Neu'],
]]);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame('lit_write_failed', $e->toError()->get_error_code());
self::assertStringContainsString('Datenbankfehler', $e->getMessage());
}
// Item 34 was refused before anything was written; item 33 gets caption and alt back.
self::assertSame([
['post_excerpt' => 'neu', 'ID' => 33],
['post_title' => 'Neu', 'ID' => 34],
['post_excerpt' => 'Alt-Unterschrift', 'ID' => 33],
], $this->postUpdates);
self::assertSame([[33, 'neu'], [33, 'alter Alt-Text']], $this->altUpdates);
}
public function testAHookThrowingAfterTheWriteAlsoRestoresThatItem(): void
{
Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) {
$this->postUpdates[] = $data;
if (($data['post_title'] ?? '') === 'Neu') {
throw new RuntimeException('save_post hook failed');
}
return (int) $data['ID'];
});
$this->expectException(WriteFailed::class);
try {
(new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => 34, 'title' => 'Neu']]]);
} finally {
self::assertSame([['post_title' => 'Neu', 'ID' => 34], ['post_title' => 'Logo', 'ID' => 34]], $this->postUpdates);
}
}
public function testRestoreRemovesAnAltTextThatDidNotExistBefore(): void
{
Functions\when('metadata_exists')->justReturn(false);
Functions\when('wp_update_post')->alias(static fn (array $data) => $data['ID'] === 34
? new WP_Error('db', 'Datenbankfehler')
: (int) $data['ID']);
Functions\expect('delete_post_meta')->once()->with(33, '_wp_attachment_image_alt')->andReturn(true);
$this->expectException(WriteFailed::class);
(new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33, 'alt' => 'neu'],
['id' => 34, 'title' => 'Neu'],
]]);
}
public function testAFailedRestoreIsAPartialWriteNamingTheChangedIds(): void
{
Functions\when('wp_update_post')->alias(static fn (array $data) => $data['ID'] === 34
? new WP_Error('db', 'Datenbankfehler')
: (int) $data['ID']);
Functions\when('update_post_meta')->alias(static function (int $id, string $key, $value) {
if ($value === 'alter Alt-Text') {
throw new RuntimeException('meta write failed');
}
return true;
});
$log = Mockery::mock(ChangeLog::class);
$log->expects('recordPartial')->once()->with('lit/media-update', 'attachment', '33', Mockery::type('string'));
try {
(new MediaUpdateAbility($log))->execute(['items' => [
['id' => 33, 'alt' => 'neu'],
['id' => 34, 'title' => 'Neu'],
]]);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
$error = $e->toError();
self::assertSame('lit_partial_write', $error->get_error_code());
self::assertSame([33], $error->get_error_data()['changed_ids']);
self::assertStringContainsString('33', $error->get_error_message());
}
}
public function testChangeLogFailureIsOnlyAWarning(): void
{
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->andThrow(new RuntimeException('db down'));
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $file);
try {
$result = (new MediaUpdateAbility($log))->execute(['items' => [['id' => 33, 'alt' => 'neu']]]);
} finally {
ini_set('error_log', (string) $previous);
unlink($file);
}
self::assertTrue($result['results'][0]['ok']);
self::assertNull($result['results'][0]['log_id']);
self::assertCount(1, $result['warnings']);
}
public function testIsIdempotentIndirectAndNotDestructive(): void
{
$ability = new MediaUpdateAbility($this->log());
self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations());
self::assertFalse($ability->isDirect());
self::assertSame(50, $ability->inputSchema()['properties']['items']['maxItems']);
self::assertStringContainsString('modified', $ability->description());
}
}