> */ 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::assertSame([99, 5, 35], array_column($failures, 'id')); self::assertStringContainsString('nicht gefunden', $failures[0]['error']); self::assertStringContainsString('Papierkorb', $failures[2]['error']); self::assertSame([], $this->postUpdates); self::assertSame([], $this->altUpdates); } public function testFailuresOfOneKindKeepTheirCodeAndReportTheNormalizedId(): void { $result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => '99', 'alt' => 'x']]]); self::assertSame('lit_not_found', $result->get_error_code()); self::assertSame(99, $result->get_error_data()['failures'][0]['id']); } 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('neu lesen', $e->toError()->get_error_data()['hint']); 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()); } }