diff --git a/src/Integrations/Media/MediaDeleteAbility.php b/src/Integrations/Media/MediaDeleteAbility.php new file mode 100644 index 0000000..3b86c5a --- /dev/null +++ b/src/Integrations/Media/MediaDeleteAbility.php @@ -0,0 +1,168 @@ +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> $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)); + } +} diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php index 5bb34f1..a16171c 100644 --- a/src/Integrations/Media/MediaIntegration.php +++ b/src/Integrations/Media/MediaIntegration.php @@ -11,7 +11,7 @@ use LIT\MCP\Limits; /** * 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 { @@ -43,6 +43,8 @@ final class MediaIntegration implements Integration new MediaUploadFromUrlAbility($importer, $uploads), new MediaUploadAbility($importer, $uploads, $this->limits), new MediaDownloadAbility($this->limits), + new MediaUpdateAbility($this->log), + new MediaDeleteAbility($finder, $this->log), ]; } } diff --git a/src/Integrations/Media/MediaMetaWriter.php b/src/Integrations/Media/MediaMetaWriter.php new file mode 100644 index 0000000..0077bcc --- /dev/null +++ b/src/Integrations/Media/MediaMetaWriter.php @@ -0,0 +1,130 @@ + posts column. */ + public const COLUMNS = ['title' => 'post_title', 'caption' => 'post_excerpt', 'description' => 'post_content']; + + /** + * The current values of everything $changes will write. + * + * @param array $changes Sanitized values by input key (alt, title, caption, description). + * @return array{id: int, columns: array, 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 $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, 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 $changes + * @return array 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)); + } + } +} diff --git a/src/Integrations/Media/MediaPresenter.php b/src/Integrations/Media/MediaPresenter.php index 22705e5..92c3319 100644 --- a/src/Integrations/Media/MediaPresenter.php +++ b/src/Integrations/Media/MediaPresenter.php @@ -29,6 +29,8 @@ class MediaPresenter 'height' => isset($meta['height']) ? (int) $meta['height'] : null, 'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null, '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']], 'filesize' => ['type' => ['integer', 'null']], 'date' => ['type' => 'string'], + 'modified' => ['type' => 'string'], ], 'required' => ['id', 'title', 'mime_type', 'url', 'alt'], ]; diff --git a/src/Integrations/Media/MediaUpdateAbility.php b/src/Integrations/Media/MediaUpdateAbility.php new file mode 100644 index 0000000..6283370 --- /dev/null +++ b/src/Integrations/Media/MediaUpdateAbility.php @@ -0,0 +1,409 @@ + 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, snapshot: array{id: int, + * columns: array, 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 $item + * @param array $seen IDs of the items before. + * @return array{attachment: WP_Post, changes: array, snapshot: array{id: int, + * columns: array, 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, snapshot: array{id: int, + * columns: array, 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, snapshot: array{id: int, + * columns: array, 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, snapshot: array{id: int, + * columns: array, alt_changed: bool, alt: ?string}}> $plan + * @return array + */ + 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 $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 $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 + ); + } +} diff --git a/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php b/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php new file mode 100644 index 0000000..e3a484f --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php @@ -0,0 +1,158 @@ + 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()); + } + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php index 47a9fa4..8fe60c4 100644 --- a/tests/Unit/Integrations/Media/MediaIntegrationTest.php +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -22,6 +22,8 @@ final class MediaIntegrationTest extends TestCase 'lit/media-upload-from-url', 'lit/media-upload', 'lit/media-download', + 'lit/media-update', + 'lit/media-delete', ]; public function testProvidesTheMediaAbilities(): void diff --git a/tests/Unit/Integrations/Media/MediaPresenterTest.php b/tests/Unit/Integrations/Media/MediaPresenterTest.php index 1be6b89..2f7c8b0 100644 --- a/tests/Unit/Integrations/Media/MediaPresenterTest.php +++ b/tests/Unit/Integrations/Media/MediaPresenterTest.php @@ -45,6 +45,7 @@ final class MediaPresenterTest extends TestCase self::assertSame('Porträt', $summary['alt']); self::assertSame(1600, $summary['width']); self::assertSame(123456, $summary['filesize']); + self::assertSame('2026-09-25 08:00:00', $summary['modified']); } public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void diff --git a/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php b/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php new file mode 100644 index 0000000..a883529 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php @@ -0,0 +1,271 @@ +> */ + private array $postUpdates = []; + + /** @var list */ + 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, '')); + 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 Team'], + ['id' => 34, 'title' => 'Firmenlogo', '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()); + } +}