Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
10906 lines
403 KiB
Markdown
10906 lines
403 KiB
Markdown
# Etappe 1a – WordPress-Basis: Inhalte, Menüs, Einstellungen – Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Claude kann über den OAuth-geschützten MCP-Server Seiten, Beiträge und Pods-Einträge generisch finden, lesen, anlegen und bearbeiten, ihren Status explizit steuern, Entwurfskopien anlegen und zurücktauschen, Revisionen nutzen, Menüs aufbauen und die freigegebenen WordPress-Einstellungen setzen – jede Änderung mit einheitlichem `ToolResult` und Protokolleintrag.
|
||
|
||
**Architecture:** Neue Integration `WordPressIntegration` (immer verfügbar) liefert die Abilities; eine `ContentSchema` führt Feldquellen zusammen (Pods → registrierte Meta → Meta-Stichprobe), `FieldValidator`/`TermAssigner`/`PostWriter` validieren alles vor dem ersten Schreibzugriff (alles oder nichts). Querschnitt: `Errors` (JSON-Fehler mit `hint`), `ToolResult` (Spec §8.1), `Cursor`, `Rev`, `ChangeLog` (Tabelle `{prefix}lit_mcp_log`, täglicher Cron), `RequestContext` (Flag-Reset nach dem Request, Client-Name für das Protokoll). Etappe 1b (separater Plan) ergänzt Medien, Einstellungsseite, Expositions-Policy, Hinweise und Aufräumen.
|
||
|
||
**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 Abilities API, `wordpress/mcp-adapter` 0.5.0, `wp-media/mcp-oauth` 1.1.2, Pods 3.3.9 (optional), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (PSR-12 + WordPress-Security), DDEV.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2, §4.3–4.5, §6.1, §6.2, §6.4, §6.5, §8.1–8.3, §9, §10, §11.3, §12 Etappe 1)
|
||
|
||
**Folgeplan:** `docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md` (Medien §6.3, Einstellungsseite §8.4, Expositions-Policy §4.3, Aufräumen, Hinweise). 1b setzt diesen Plan voraus.
|
||
|
||
## Global Constraints
|
||
|
||
- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Das installierte, inaktive Pods darf in DDEV für Integrationstests aktiviert werden (danach wieder deaktivieren); keine neuen Plugins installieren.
|
||
- PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3); Plugin-Header `Tested up to: 7.0`.
|
||
- Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` müssen nach jedem Task sauber sein.
|
||
- Unit-Tests: PHPUnit 9.6 + Brain Monkey + Mockery über `LIT\MCP\Tests\Unit\TestCase`; Stubs für WP-Klassen in `tests/Stubs/` (in `tests/bootstrap.php` eingebunden). Funktionen, die `TestCase::setUp()` per `Functions\stubs()` vorbelegt (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`), nie mit `Functions\expect()` prüfen – Brain Monkey verbietet Stub und Erwartung auf derselben Funktion.
|
||
- Integrationstests laufen im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh`; WP-CLI über `timeout 120 ddev wp …`.
|
||
- Ability-Namen `lit/<bereich>-<aktion>` → Tool-Namen `lit-<bereich>-<aktion>`. Annotations exakt: Lese-Tools `readonly: true`; `destructive: true` nur für Löschen, Ersetzen, Austausch (Papierkorb, Kopie-Austausch, Revision wiederherstellen, Menübaum ersetzen); `idempotent` wie je Task angegeben.
|
||
- Input- und Output-Schemas als JSON Schema 2020-12, nur mit Schlüsselwörtern, die `rest_validate_value_from_schema()` prüft (`type` inkl. Typ-Listen, `properties`, `required`, `items`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `minItems`, `maxItems`, `additionalProperties`). WordPress prüft Eingaben vor `execute()` und Ausgaben danach – eine Ausgabe, die nicht zum `outputSchema` passt, wird zum Fehler.
|
||
- Fachliche Fehler ausschließlich über `LIT\MCP\Abilities\Errors` (Code `lit_<code>`, Nachricht = JSON `{code, message, hint, …}`), nie Stacktraces.
|
||
- Schreib-Tools ändern nie den Veröffentlichungsstatus; neue Einträge immer als Entwurf; Beiträge nur in den Papierkorb, nie endgültig löschen (einzige Ausnahme: Menüeinträge `nav_menu_item`, die WordPress selbst ohne Papierkorb löscht – Task 14).
|
||
- Tools mit `destructive: true` verlangen `confirm: true`, sonst `lit_confirm_required` ohne jede Änderung.
|
||
- Jede erfolgreiche Änderung schreibt einen Protokolleintrag (`ChangeLog::record`) und liefert `ToolResult` nach Spec §8.1; `revision_id` ist der Wiederherstellungspunkt **vor** der Änderung (oder `null`), `rev` der Hash des `post_content` danach (oder `null` bei Nicht-Beiträgen).
|
||
- Listen mit Cursor-Paginierung: `limit` 1–100 (Standard 20), `cursor` rein, `next_cursor` (oder `null`) raus.
|
||
- Keine Divi-Tools in dieser Etappe; Divi-Inhalte werden erkannt (`_et_pb_use_builder = on` oder `<!-- wp:divi/` im Inhalt) und nur per Texthinweis auf `lit-divi-outline` verwiesen; `post_content` von Divi-Seiten wird nicht überschrieben.
|
||
- WP-Einstellungen ausschließlich über die Whitelist aus Spec §6.5; `admin_email`, `users_can_register`, `default_role`, `siteurl`, `home` sind gesperrt.
|
||
- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` mit den Trailern `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`.
|
||
|
||
## Review Focus
|
||
|
||
1. **Veröffentlichte Seite wird per `lit-content-update` geändert:** Status bleibt `publish`, vorher existiert ein Wiederherstellungspunkt (`revision_id`) → Test in Task 9 (`wp_update_post` ohne `post_status`, `RevisionPoint` vor dem Schreiben).
|
||
2. **Divi-Seite + `content` im Update:** Rohtext-Überschreiben wird mit `lit_unsupported` abgelehnt, nichts geschrieben; Titel/Felder ohne `content` gehen weiter → Test in Task 9.
|
||
3. **Pods-Pflichtfeld fehlt beim Anlegen oder wird beim Update geleert, Auswahlwert ungültig, Beziehung auf falschen Typ:** `lit_validation_failed` mit `fields.<key>`, kein Eintrag entsteht → Tests in Task 7 und Task 9.
|
||
4. **Zeitpunkte:** Planen in der Vergangenheit oder im falschen Format wird abgelehnt; Veröffentlichen eines geplanten oder zukunftsdatierten Entwurfs setzt das Datum auf „jetzt“, statt still wieder `future` zu werden → Tests in Task 10.
|
||
5. **Menü-Gesamtbaum ohne die bestehenden Einträge (oder `remove`-Operation):** ohne `confirm: true` → `lit_confirm_required` mit Liste der betroffenen Einträge, nichts geschrieben; Antwort enthält immer `previous_items` zum Zurückbauen → Test in Task 14.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
wp-lit-mcp.php Header (Tested up to), Aktivierungs-/Deaktivierungs-Hooks
|
||
src/
|
||
Plugin.php verdrahtet ChangeLog, Schema, WordPressIntegration
|
||
Limits.php Option lit_mcp_limits (Upload-MB, Protokoll-Tage, Snapshots)
|
||
Lifecycle.php Aktivierung (Tabelle, Cron), Deaktivierung (Cron)
|
||
Abilities/
|
||
Errors.php (geändert) JSON-Fehler mit hint, neue Codes
|
||
ToolResult.php einheitliche Rückmeldung §8.1 + Schema
|
||
Cursor.php Cursor-Paginierung
|
||
Rev.php Inhalts-Hash für die optimistische Sperre
|
||
JsonMap.php leere Maps als {} statt []
|
||
ChangeLog/
|
||
ChangeLog.php Tabelle lit_mcp_log: record, purgeOlderThan
|
||
ChangeLogInstaller.php dbDelta, Schema-Version
|
||
ChangeLogCleanup.php täglicher Cron
|
||
Guides/InstructionsBuilder.php (geändert) Inhaltstypen, rev-Regel
|
||
Server/RequestContext.php (geändert) Reset nach Request, Client-Name
|
||
Integrations/
|
||
WordPress/
|
||
WordPressIntegration.php liefert alle Abilities dieses Plans
|
||
PostGuard.php lädt Eintrag + Rechteprüfung
|
||
RevisionPoint.php Wiederherstellungspunkt vor Änderungen
|
||
Schema/
|
||
FieldDefinition.php Wertobjekt eines Feldes
|
||
FieldSource.php Interface: Felder beschreiben, lesen, schreiben
|
||
MetaFieldSource.php Basis: Lesen/Schreiben über die Meta-API
|
||
RegisteredMetaSource.php get_registered_meta_keys()
|
||
SampledMetaSource.php Meta-Schlüssel-Stichprobe
|
||
ContentSchema.php Post-Typen, Taxonomien, zusammengeführte Felder
|
||
FieldValidator.php Feldwerte gegen Schema prüfen
|
||
TermAssigner.php Begriffe auflösen und zuweisen
|
||
Content/
|
||
PostPresenter.php Ausgabeformat eines Eintrags
|
||
PostWriter.php gemeinsame Validierung/Schreiblogik create/update
|
||
ContentTypesAbility.php lit/content-types
|
||
ContentQueryAbility.php lit/content-query
|
||
ContentGetAbility.php lit/content-get
|
||
ContentCreateAbility.php lit/content-create
|
||
ContentUpdateAbility.php lit/content-update
|
||
Status/
|
||
PostPublishAbility.php lit/post-publish
|
||
PostUnpublishAbility.php lit/post-unpublish
|
||
PostScheduleAbility.php lit/post-schedule
|
||
PostTrashAbility.php lit/post-trash
|
||
PostCloner.php Kopie anlegen / Kopie ins Original übernehmen
|
||
PostDuplicateAbility.php lit/post-duplicate
|
||
PostReplaceWithCopyAbility.php lit/post-replace-with-copy
|
||
Revisions/
|
||
RevisionsListAbility.php lit/revisions-list
|
||
RevisionRestoreAbility.php lit/revision-restore
|
||
Menus/
|
||
MenuTree.php Menüeinträge → Baum
|
||
MenuLocations.php Positionen zuweisen
|
||
MenuOperations.php Einzeloperationen → Zielbaum
|
||
MenuWriter.php Zielbaum prüfen und schreiben
|
||
MenuListAbility.php lit/menu-list
|
||
MenuGetAbility.php lit/menu-get
|
||
MenuCreateAbility.php lit/menu-create
|
||
MenuSetItemsAbility.php lit/menu-set-items
|
||
MenuAssignLocationAbility.php lit/menu-assign-location
|
||
Settings/
|
||
SettingsWhitelist.php Whitelist §6.5 inkl. Validierung
|
||
WpSettingsGetAbility.php lit/wp-settings-get
|
||
WpSettingsUpdateAbility.php lit/wp-settings-update
|
||
Pods/
|
||
PodsFieldSource.php Pods-Felder: Definition, Lesen, Schreiben via Pods-API
|
||
docs/guides/overview.md (geändert) Werkzeugliste
|
||
docs/guides/content-types.md Anleitung Inhaltstypen/Felder
|
||
docs/guides/workflows.md Anleitung Website aufsetzen
|
||
tests/
|
||
bootstrap.php (geändert) weitere Stubs
|
||
Stubs/WpPost.php, WpTerm.php, Wpdb.php neue Minimal-Stubs
|
||
Stubs/WpRestRequest.php (geändert) Header
|
||
Unit/… Tests je Klasse (Pfade spiegeln src/)
|
||
Integration/pods-fixture.php Pods-Testtyp lit_person anlegen/entfernen
|
||
Integration/lib.sh gemeinsame Shell-Helfer (Token, tool-Aufruf)
|
||
Integration/wp-basis-smoke.sh Ende-zu-Ende-Test aller Tools dieses Plans
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Rückmeldungs-Grundlagen (`Errors`, `ToolResult`, `Cursor`, `Rev`, `JsonMap`) und Plugin-Header
|
||
|
||
Der Adapter (v0.5) reicht von einem `WP_Error` nur `get_error_message()` als Text an den Client weiter (`ToolsHandler::create_error_result`). Damit Claude trotzdem `{code, message, hint, fields?}` (Spec §8.2) bekommt, trägt die Fehlermeldung diese Struktur als JSON. Außerdem entsteht die einheitliche Erfolgsrückmeldung.
|
||
|
||
**Files:**
|
||
- Modify: `src/Abilities/Errors.php` (komplett ersetzen), `wp-lit-mcp.php` (Header), `tests/Unit/TestCase.php`
|
||
- Create: `src/Abilities/ToolResult.php`, `src/Abilities/Cursor.php`, `src/Abilities/Rev.php`, `src/Abilities/JsonMap.php`
|
||
- Test: `tests/Unit/Abilities/ErrorsTest.php`, `tests/Unit/Abilities/ToolResultTest.php`, `tests/Unit/Abilities/CursorTest.php`, `tests/Unit/Abilities/RevAndJsonMapTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: bestehendes `Errors::forbidden(string)`, `Errors::invalidInput(string, array)`, `Errors::notFound(string)`, `Errors::internal()` (Aufrufer in `AbilityRegistrar`, `GuideAbility` bleiben unverändert gültig).
|
||
- Produces:
|
||
- `Errors::make(string $code, string $message, string $hint, int $status, array $details = []): WP_Error` – Code `lit_<code>`, Nachricht JSON, Daten `['status' => …, 'hint' => …] + $details`.
|
||
- `Errors::forbidden(string $message, string $hint = …)`, `Errors::invalidInput(string $message, array $fields = [], string $hint = …)`, `Errors::notFound(string $message, string $hint = …)`, `Errors::revMismatch()`, `Errors::inUse(string $message, array $usages)`, `Errors::confirmRequired(string $message, array $details = [])`, `Errors::unsupported(string $message, string $hint)`, `Errors::internal()` – alle `WP_Error`.
|
||
- `ToolResult::ok(string $summary): ToolResult`; Builder `changed(string $id, string $type, string $action, string $label = '')`, `warning(string)`, `warnings(list<string>)`, `outline(?string)`, `rev(?string)`, `revisionId(?int)`, `snapshotId(?string)`, `logId(?int)`, `urls(?string $previewUrl, ?string $editUrl)`, `with(string $key, mixed $value)` (jeweils `: self`); `toArray(): array`; `ToolResult::schema(array $extraProperties = [], array $extraRequired = []): array`.
|
||
- `Cursor::encode(int $offset): string`, `Cursor::decode(?string $cursor): ?int` (null = ungültig, `''`/null-Eingabe = 0), `Cursor::limit(mixed $limit, int $default = 20): int`, `Cursor::page(array $rows, int $limit, int $offset): array{items: list<mixed>, next_cursor: ?string}`, `Cursor::invalid(): WP_Error`, `Cursor::inputProperties(): array`.
|
||
- `Rev::of(string $content): string` (12 Hex-Zeichen).
|
||
- `JsonMap::of(array $map): array|\stdClass` (leer → `stdClass`, damit JSON `{}` entsteht).
|
||
|
||
- [ ] **Step 1: TestCase um Standard-Stubs erweitern**
|
||
|
||
`tests/Unit/TestCase.php` (komplett ersetzen):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit;
|
||
|
||
use Brain\Monkey;
|
||
use Brain\Monkey\Functions;
|
||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||
use PHPUnit\Framework\TestCase as BaseTestCase;
|
||
|
||
abstract class TestCase extends BaseTestCase
|
||
{
|
||
use MockeryPHPUnitIntegration;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Monkey\setUp();
|
||
|
||
// Pure helpers with obvious behaviour. Never use Functions\expect() on these
|
||
// names in a test: Brain Monkey forbids a stub and an expectation for the
|
||
// same function. Functions\when() may still redefine them.
|
||
Functions\stubs([
|
||
'wp_json_encode' => static fn ($data, int $flags = 0) => json_encode($data, $flags),
|
||
'is_wp_error' => static fn ($thing): bool => $thing instanceof \WP_Error,
|
||
'absint' => static fn ($value): int => abs((int) $value),
|
||
'wp_slash' => static fn ($value) => $value,
|
||
'wp_unslash' => static fn ($value) => $value,
|
||
'sanitize_text_field' => static fn ($value): string => trim(strip_tags((string) $value)),
|
||
]);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
Monkey\tearDown();
|
||
parent::tearDown();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Failing Tests**
|
||
|
||
`tests/Unit/Abilities/ErrorsTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class ErrorsTest extends TestCase
|
||
{
|
||
public function testMessageCarriesCodeMessageHintAndFieldsAsJson(): void
|
||
{
|
||
$error = Errors::invalidInput('Titel fehlt.', ['title' => 'Pflichtfeld']);
|
||
|
||
self::assertSame('lit_validation_failed', $error->get_error_code());
|
||
$payload = json_decode($error->get_error_message(), true);
|
||
self::assertSame('validation_failed', $payload['code']);
|
||
self::assertSame('Titel fehlt.', $payload['message']);
|
||
self::assertNotSame('', $payload['hint']);
|
||
self::assertSame(['title' => 'Pflichtfeld'], $payload['fields']);
|
||
self::assertSame(400, $error->get_error_data()['status']);
|
||
self::assertSame($payload['hint'], $error->get_error_data()['hint']);
|
||
}
|
||
|
||
public function testUmlautsStayReadableInTheMessage(): void
|
||
{
|
||
$error = Errors::forbidden('Dein Benutzerkonto ist für LIT MCP nicht freigegeben.');
|
||
|
||
self::assertStringContainsString('für LIT MCP nicht freigegeben', $error->get_error_message());
|
||
}
|
||
|
||
public function testInUseCarriesTheUsages(): void
|
||
{
|
||
$error = Errors::inUse('Bild wird verwendet.', [['post_id' => 7, 'where' => 'featured']]);
|
||
|
||
$payload = json_decode($error->get_error_message(), true);
|
||
self::assertSame('lit_in_use', $error->get_error_code());
|
||
self::assertSame(7, $payload['usages'][0]['post_id']);
|
||
}
|
||
|
||
public function testEveryFactoryUsesLitPrefixAndANonEmptyHint(): void
|
||
{
|
||
$errors = [
|
||
Errors::forbidden('x'),
|
||
Errors::notFound('x'),
|
||
Errors::revMismatch(),
|
||
Errors::confirmRequired('x'),
|
||
Errors::unsupported('x', 'y'),
|
||
Errors::internal(),
|
||
];
|
||
|
||
foreach ($errors as $error) {
|
||
self::assertStringStartsWith('lit_', $error->get_error_code());
|
||
self::assertNotSame('', json_decode($error->get_error_message(), true)['hint']);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Abilities/ToolResultTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class ToolResultTest extends TestCase
|
||
{
|
||
public function testDefaultsMatchTheSpecShape(): void
|
||
{
|
||
self::assertSame(
|
||
[
|
||
'ok' => true,
|
||
'summary' => 'Seite angelegt',
|
||
'changed' => [],
|
||
'outline' => null,
|
||
'rev' => null,
|
||
'warnings' => [],
|
||
'revision_id' => null,
|
||
'snapshot_id' => null,
|
||
'log_id' => null,
|
||
'preview_url' => null,
|
||
'edit_url' => null,
|
||
],
|
||
ToolResult::ok('Seite angelegt')->toArray()
|
||
);
|
||
}
|
||
|
||
public function testBuilderFillsAllFieldsAndExtras(): void
|
||
{
|
||
$result = ToolResult::ok('Aktualisiert')
|
||
->changed('12', 'page', 'updated', 'Startseite')
|
||
->warning('Bild ohne Alt-Text')
|
||
->warnings(['Zweite Warnung'])
|
||
->outline('Hero')
|
||
->rev('a1b2c3d4e5f6')
|
||
->revisionId(812)
|
||
->snapshotId('snap-1')
|
||
->logId(4711)
|
||
->urls('https://kunde.de/?p=12&preview=true', 'https://kunde.de/wp-admin/post.php?post=12&action=edit')
|
||
->with('previous', ['blogname' => 'Alt'])
|
||
->toArray();
|
||
|
||
self::assertSame([['id' => '12', 'type' => 'page', 'action' => 'updated', 'label' => 'Startseite']], $result['changed']);
|
||
self::assertSame(['Bild ohne Alt-Text', 'Zweite Warnung'], $result['warnings']);
|
||
self::assertSame('Hero', $result['outline']);
|
||
self::assertSame('a1b2c3d4e5f6', $result['rev']);
|
||
self::assertSame(812, $result['revision_id']);
|
||
self::assertSame('snap-1', $result['snapshot_id']);
|
||
self::assertSame(4711, $result['log_id']);
|
||
self::assertSame('https://kunde.de/?p=12&preview=true', $result['preview_url']);
|
||
self::assertSame(['blogname' => 'Alt'], $result['previous']);
|
||
}
|
||
|
||
public function testReservedKeysCannotBeOverwrittenByExtras(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
ToolResult::ok('x')->with('ok', false);
|
||
}
|
||
|
||
public function testSchemaRequiresCoreKeysAndAcceptsExtraProperties(): void
|
||
{
|
||
$schema = ToolResult::schema(['previous' => ['type' => 'object']], ['previous']);
|
||
|
||
self::assertSame('object', $schema['type']);
|
||
self::assertContains('summary', $schema['required']);
|
||
self::assertContains('previous', $schema['required']);
|
||
self::assertSame(['integer', 'null'], $schema['properties']['revision_id']['type']);
|
||
self::assertSame(['type' => 'object'], $schema['properties']['previous']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Abilities/CursorTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class CursorTest extends TestCase
|
||
{
|
||
public function testRoundTrip(): void
|
||
{
|
||
self::assertSame(40, Cursor::decode(Cursor::encode(40)));
|
||
}
|
||
|
||
public function testMissingCursorStartsAtZero(): void
|
||
{
|
||
self::assertSame(0, Cursor::decode(null));
|
||
self::assertSame(0, Cursor::decode(''));
|
||
}
|
||
|
||
public function testTamperedCursorIsRejected(): void
|
||
{
|
||
self::assertNull(Cursor::decode('not-a-cursor'));
|
||
self::assertNull(Cursor::decode(base64_encode('o:-5')));
|
||
self::assertNull(Cursor::decode(base64_encode('o:12;DROP')));
|
||
}
|
||
|
||
public function testLimitIsClamped(): void
|
||
{
|
||
self::assertSame(20, Cursor::limit(null));
|
||
self::assertSame(1, Cursor::limit(0));
|
||
self::assertSame(100, Cursor::limit(500));
|
||
self::assertSame(7, Cursor::limit(7));
|
||
}
|
||
|
||
public function testPageDetectsMoreRows(): void
|
||
{
|
||
$page = Cursor::page([1, 2, 3], 2, 10);
|
||
|
||
self::assertSame([1, 2], $page['items']);
|
||
self::assertSame(12, Cursor::decode($page['next_cursor']));
|
||
self::assertNull(Cursor::page([1, 2], 2, 0)['next_cursor']);
|
||
}
|
||
|
||
public function testInvalidCursorErrorIsAValidationError(): void
|
||
{
|
||
self::assertSame('lit_validation_failed', Cursor::invalid()->get_error_code());
|
||
}
|
||
|
||
public function testInputPropertiesDescribeLimitAndCursor(): void
|
||
{
|
||
$properties = Cursor::inputProperties();
|
||
|
||
self::assertSame(100, $properties['limit']['maximum']);
|
||
self::assertSame('string', $properties['cursor']['type']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Abilities/RevAndJsonMapTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||
|
||
use LIT\MCP\Abilities\JsonMap;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use stdClass;
|
||
|
||
final class RevAndJsonMapTest extends TestCase
|
||
{
|
||
public function testRevIsShortDeterministicAndContentSensitive(): void
|
||
{
|
||
self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', Rev::of('abc'));
|
||
self::assertSame(Rev::of('abc'), Rev::of('abc'));
|
||
self::assertNotSame(Rev::of('abc'), Rev::of('abd'));
|
||
}
|
||
|
||
public function testEmptyMapBecomesJsonObject(): void
|
||
{
|
||
self::assertInstanceOf(stdClass::class, JsonMap::of([]));
|
||
self::assertSame('{}', json_encode(JsonMap::of([])));
|
||
self::assertSame(['a' => 1], JsonMap::of(['a' => 1]));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run – muss fehlschlagen**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test -- --filter 'ErrorsTest|ToolResultTest|CursorTest|RevAndJsonMapTest'`
|
||
Expected: FAIL (Klassen `ToolResult`, `Cursor`, `Rev`, `JsonMap` fehlen; `ErrorsTest` scheitert, weil die Nachricht kein JSON ist).
|
||
|
||
- [ ] **Step 4: Implementierung**
|
||
|
||
`src/Abilities/Errors.php` (komplett ersetzen):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Factory for the error objects abilities return.
|
||
*
|
||
* mcp-adapter 0.5 turns a WP_Error into a tool result with isError: true but
|
||
* forwards only the message text. The message therefore carries the whole
|
||
* structured error as JSON: {code, message, hint, fields?, usages?, …} (spec §8.2).
|
||
*/
|
||
final class Errors
|
||
{
|
||
/**
|
||
* @param array<string, mixed> $details Extra keys for the client, e.g. fields or usages.
|
||
*/
|
||
public static function make(string $code, string $message, string $hint, int $status, array $details = []): WP_Error
|
||
{
|
||
$payload = array_merge(['code' => $code, 'message' => $message, 'hint' => $hint], $details);
|
||
$json = wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
|
||
return new WP_Error(
|
||
'lit_' . $code,
|
||
is_string($json) ? $json : $message,
|
||
array_merge(['status' => $status, 'hint' => $hint], $details)
|
||
);
|
||
}
|
||
|
||
public static function forbidden(
|
||
string $message,
|
||
string $hint = 'Mit den Rechten des verbundenen WordPress-Kontos nicht erlaubt; ggf. Administrator fragen.'
|
||
): WP_Error {
|
||
return self::make('forbidden', $message, $hint, 403);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, string> $fields Input path => problem.
|
||
*/
|
||
public static function invalidInput(
|
||
string $message,
|
||
array $fields = [],
|
||
string $hint = 'Die unter fields genannten Eingaben korrigieren und erneut senden.'
|
||
): WP_Error {
|
||
return self::make('validation_failed', $message, $hint, 400, $fields === [] ? [] : ['fields' => $fields]);
|
||
}
|
||
|
||
public static function notFound(
|
||
string $message,
|
||
string $hint = 'ID prüfen, z. B. über lit-content-query oder lit-media-search suchen.'
|
||
): WP_Error {
|
||
return self::make('not_found', $message, $hint, 404);
|
||
}
|
||
|
||
public static function revMismatch(): WP_Error
|
||
{
|
||
return self::make(
|
||
'rev_mismatch',
|
||
'Der Inhalt wurde seit dem letzten Lesen geändert; nichts wurde geschrieben.',
|
||
'Neu lesen (z. B. lit-content-get) und die Änderung auf dem aktuellen Stand wiederholen.',
|
||
409
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string, mixed>> $usages
|
||
*/
|
||
public static function inUse(string $message, array $usages): WP_Error
|
||
{
|
||
return self::make(
|
||
'in_use',
|
||
$message,
|
||
'Verwendungen prüfen; nur mit force: true trotzdem ausführen.',
|
||
409,
|
||
['usages' => $usages]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $details
|
||
*/
|
||
public static function confirmRequired(string $message, array $details = []): WP_Error
|
||
{
|
||
return self::make(
|
||
'confirm_required',
|
||
$message,
|
||
'Beim Nutzer nachfragen und dann mit confirm: true erneut aufrufen.',
|
||
409,
|
||
$details
|
||
);
|
||
}
|
||
|
||
public static function unsupported(string $message, string $hint): WP_Error
|
||
{
|
||
return self::make('unsupported', $message, $hint, 400);
|
||
}
|
||
|
||
public static function internal(): WP_Error
|
||
{
|
||
return self::make(
|
||
'internal_error',
|
||
'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.',
|
||
'Später erneut versuchen oder den Administrator der Website informieren.',
|
||
500
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Abilities/ToolResult.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
use InvalidArgumentException;
|
||
|
||
/**
|
||
* Uniform success result of every writing ability (spec §8.1).
|
||
* The adapter returns it as structuredContent plus JSON text.
|
||
*/
|
||
final class ToolResult
|
||
{
|
||
private const RESERVED = [
|
||
'ok', 'summary', 'changed', 'outline', 'rev', 'warnings',
|
||
'revision_id', 'snapshot_id', 'log_id', 'preview_url', 'edit_url',
|
||
];
|
||
|
||
/** @var list<array{id: string, type: string, action: string, label: string}> */
|
||
private array $changed = [];
|
||
|
||
/** @var list<string> */
|
||
private array $warnings = [];
|
||
|
||
private ?string $outline = null;
|
||
private ?string $rev = null;
|
||
private ?int $revisionId = null;
|
||
private ?string $snapshotId = null;
|
||
private ?int $logId = null;
|
||
private ?string $previewUrl = null;
|
||
private ?string $editUrl = null;
|
||
|
||
/** @var array<string, mixed> */
|
||
private array $extra = [];
|
||
|
||
private function __construct(private string $summary)
|
||
{
|
||
}
|
||
|
||
public static function ok(string $summary): self
|
||
{
|
||
return new self($summary);
|
||
}
|
||
|
||
public function changed(string $id, string $type, string $action, string $label = ''): self
|
||
{
|
||
$this->changed[] = ['id' => $id, 'type' => $type, 'action' => $action, 'label' => $label];
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function warning(string $warning): self
|
||
{
|
||
$this->warnings[] = $warning;
|
||
|
||
return $this;
|
||
}
|
||
|
||
/** @param list<string> $warnings */
|
||
public function warnings(array $warnings): self
|
||
{
|
||
foreach ($warnings as $warning) {
|
||
$this->warning($warning);
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function outline(?string $outline): self
|
||
{
|
||
$this->outline = $outline;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function rev(?string $rev): self
|
||
{
|
||
$this->rev = $rev;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function revisionId(?int $revisionId): self
|
||
{
|
||
$this->revisionId = $revisionId;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function snapshotId(?string $snapshotId): self
|
||
{
|
||
$this->snapshotId = $snapshotId;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function logId(?int $logId): self
|
||
{
|
||
$this->logId = $logId;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function urls(?string $previewUrl, ?string $editUrl): self
|
||
{
|
||
$this->previewUrl = $previewUrl === '' ? null : $previewUrl;
|
||
$this->editUrl = $editUrl === '' ? null : $editUrl;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function with(string $key, mixed $value): self
|
||
{
|
||
if (in_array($key, self::RESERVED, true)) {
|
||
throw new InvalidArgumentException(sprintf('ToolResult key "%s" is reserved.', $key));
|
||
}
|
||
|
||
$this->extra[$key] = $value;
|
||
|
||
return $this;
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function toArray(): array
|
||
{
|
||
return array_merge(
|
||
[
|
||
'ok' => true,
|
||
'summary' => $this->summary,
|
||
'changed' => $this->changed,
|
||
'outline' => $this->outline,
|
||
'rev' => $this->rev,
|
||
'warnings' => $this->warnings,
|
||
'revision_id' => $this->revisionId,
|
||
'snapshot_id' => $this->snapshotId,
|
||
'log_id' => $this->logId,
|
||
'preview_url' => $this->previewUrl,
|
||
'edit_url' => $this->editUrl,
|
||
],
|
||
$this->extra
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array<string, mixed>> $extraProperties
|
||
* @param list<string> $extraRequired
|
||
* @return array<string, mixed>
|
||
*/
|
||
public static function schema(array $extraProperties = [], array $extraRequired = []): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge(
|
||
[
|
||
'ok' => ['type' => 'boolean'],
|
||
'summary' => ['type' => 'string'],
|
||
'changed' => [
|
||
'type' => 'array',
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'string'],
|
||
'type' => ['type' => 'string'],
|
||
'action' => ['type' => 'string'],
|
||
'label' => ['type' => 'string'],
|
||
],
|
||
'required' => ['id', 'type', 'action', 'label'],
|
||
],
|
||
],
|
||
'outline' => ['type' => ['string', 'null']],
|
||
'rev' => ['type' => ['string', 'null']],
|
||
'warnings' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
'revision_id' => ['type' => ['integer', 'null']],
|
||
'snapshot_id' => ['type' => ['string', 'null']],
|
||
'log_id' => ['type' => ['integer', 'null']],
|
||
'preview_url' => ['type' => ['string', 'null']],
|
||
'edit_url' => ['type' => ['string', 'null']],
|
||
],
|
||
$extraProperties
|
||
),
|
||
'required' => array_merge(
|
||
['ok', 'summary', 'changed', 'warnings', 'rev', 'revision_id', 'log_id'],
|
||
$extraRequired
|
||
),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Abilities/Cursor.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Opaque offset cursor for list tools (spec §8.1, §11.3).
|
||
*/
|
||
final class Cursor
|
||
{
|
||
public const DEFAULT_LIMIT = 20;
|
||
public const MAX_LIMIT = 100;
|
||
|
||
public static function encode(int $offset): string
|
||
{
|
||
return rtrim(strtr(base64_encode('o:' . $offset), '+/', '-_'), '=');
|
||
}
|
||
|
||
/** @return int|null Offset, 0 for a missing cursor, null for an invalid one. */
|
||
public static function decode(?string $cursor): ?int
|
||
{
|
||
if ($cursor === null || $cursor === '') {
|
||
return 0;
|
||
}
|
||
|
||
$raw = base64_decode(strtr($cursor, '-_', '+/'), true);
|
||
if ($raw === false || preg_match('/^o:(\d{1,9})$/', $raw, $matches) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
return (int) $matches[1];
|
||
}
|
||
|
||
public static function limit(mixed $limit, int $default = self::DEFAULT_LIMIT): int
|
||
{
|
||
if ($limit === null) {
|
||
return $default;
|
||
}
|
||
|
||
return max(1, min(self::MAX_LIMIT, (int) $limit));
|
||
}
|
||
|
||
/**
|
||
* @param list<mixed> $rows Rows fetched with $limit + 1.
|
||
* @return array{items: list<mixed>, next_cursor: ?string}
|
||
*/
|
||
public static function page(array $rows, int $limit, int $offset): array
|
||
{
|
||
return [
|
||
'items' => array_slice($rows, 0, $limit),
|
||
'next_cursor' => count($rows) > $limit ? self::encode($offset + $limit) : null,
|
||
];
|
||
}
|
||
|
||
public static function invalid(): WP_Error
|
||
{
|
||
return Errors::invalidInput(
|
||
'Ungültiger cursor.',
|
||
['cursor' => 'unbekanntes Format'],
|
||
'cursor unverändert aus next_cursor der letzten Antwort übernehmen oder weglassen.'
|
||
);
|
||
}
|
||
|
||
/** @return array<string, array<string, mixed>> */
|
||
public static function inputProperties(): array
|
||
{
|
||
return [
|
||
'limit' => [
|
||
'type' => 'integer',
|
||
'minimum' => 1,
|
||
'maximum' => self::MAX_LIMIT,
|
||
'description' => 'Einträge pro Seite (Standard 20).',
|
||
],
|
||
'cursor' => [
|
||
'type' => 'string',
|
||
'description' => 'next_cursor der vorherigen Antwort, um weiterzublättern.',
|
||
],
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Abilities/Rev.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
/**
|
||
* Short hash of post_content – the optimistic-lock handle ("rev", spec §5.3).
|
||
*/
|
||
final class Rev
|
||
{
|
||
public static function of(string $content): string
|
||
{
|
||
return substr(hash('sha256', $content), 0, 12);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Abilities/JsonMap.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Abilities;
|
||
|
||
use stdClass;
|
||
|
||
/**
|
||
* PHP encodes an empty array as JSON [] – for schema type "object" the client
|
||
* must see {}. WordPress' output validation accepts stdClass for objects.
|
||
*/
|
||
final class JsonMap
|
||
{
|
||
/**
|
||
* @param array<string, mixed> $map
|
||
* @return array<string, mixed>|stdClass
|
||
*/
|
||
public static function of(array $map): array|stdClass
|
||
{
|
||
return $map === [] ? new stdClass() : $map;
|
||
}
|
||
}
|
||
```
|
||
|
||
`wp-lit-mcp.php`: im Header-Kommentar direkt unter ` * Requires PHP: 8.1` die Zeile ` * Tested up to: 7.0` einfügen, sonst nichts ändern.
|
||
|
||
- [ ] **Step 5: Run – muss bestehen (inkl. aller bisherigen Tests)**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests PASS (auch `AbilityRegistrarTest`, `GuideAbilityTest` – sie prüfen nur Code bzw. enthaltene Texte), phpcs ohne Fehler.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Abilities tests/Unit/TestCase.php tests/Unit/Abilities wp-lit-mcp.php
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: structured errors with hint, ToolResult, cursor pagination
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 2: `RequestContext` – Reset nach dem Request und Client-Name
|
||
|
||
Aus Etappe 0 offen (Ruling 11): Das Flag „läuft gerade unser OAuth-Request“ wird nie zurückgesetzt – in persistenten PHP-Laufzeiten oder bei `rest_do_request()` könnte ein späterer Aufruf im selben Prozess die Routen-Prüfung passieren. Zusätzlich braucht das Protokoll (Spec §8.3, Spalte `client`) den Namen des verbundenen OAuth-Clients; mcp-oauth speichert ihn als Namen des Application Passwords, dessen UUID im JWT (`app_pass_id`) steht.
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/RequestContext.php` (komplett ersetzen), `tests/Stubs/WpRestRequest.php` (komplett ersetzen)
|
||
- Test: `tests/Unit/Server/RequestContextTest.php` (komplett ersetzen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ServerRegistrar::NAMESPACE`, `ServerRegistrar::ROUTE`; `WPMedia\MCP\OAuth\Auth\JWT::decode(string $token, string $secret): ?array`, `WPMedia\MCP\OAuth\Auth\SecretManager::get_secret(): string`, `WP_Application_Passwords::get_user_application_password(int $userId, string $uuid): ?array`.
|
||
- Produces:
|
||
- `RequestContext::register()` hängt `recordRoute` an `rest_request_before_callbacks` **und** `clearRoute` an `rest_request_after_callbacks` (je Priorität 10, 3 Argumente).
|
||
- `RequestContext::clearRoute(mixed $response, array $handler, WP_REST_Request $request): mixed` – setzt alles zurück, wenn die Route unsere ist; gibt `$response` unverändert zurück.
|
||
- `RequestContext::clientName(): string` – Name des Application Passwords, sonst `client_id` aus dem Token, sonst `''`.
|
||
- `RequestContext::reset(bool $isOAuthServerRequest = false, ?string $clientName = null): void` (Test-Hilfe; zweiter Parameter neu).
|
||
|
||
- [ ] **Step 1: Stub für Request-Header erweitern**
|
||
|
||
`tests/Stubs/WpRestRequest.php` (komplett ersetzen):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! class_exists('WP_REST_Request')) {
|
||
class WP_REST_Request
|
||
{
|
||
/** @param array<string, string> $headers Lower-case header name => value. */
|
||
public function __construct(private string $route = '', private array $headers = [])
|
||
{
|
||
}
|
||
|
||
public function get_route(): string
|
||
{
|
||
return $this->route;
|
||
}
|
||
|
||
public function get_header(string $key): ?string
|
||
{
|
||
return $this->headers[strtolower($key)] ?? null;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Failing Tests**
|
||
|
||
`tests/Unit/Server/RequestContextTest.php` (komplett ersetzen):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Server;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Server\RequestContext;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use stdClass;
|
||
use WP_REST_Request;
|
||
use WPMedia\MCP\OAuth\Auth\JWT;
|
||
|
||
final class RequestContextTest extends TestCase
|
||
{
|
||
private const ROUTE = '/mcp/mcp-oauth-server';
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
RequestContext::reset();
|
||
parent::tearDown();
|
||
}
|
||
|
||
public function testIsOAuthServerRequestIsFalseByDefault(): void
|
||
{
|
||
self::assertFalse(RequestContext::isOAuthServerRequest());
|
||
}
|
||
|
||
public function testRecordRouteMarksTheOAuthServerRoute(): void
|
||
{
|
||
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE));
|
||
|
||
self::assertTrue(RequestContext::isOAuthServerRequest());
|
||
}
|
||
|
||
public function testRecordRouteIgnoresOtherRoutes(): void
|
||
{
|
||
RequestContext::recordRoute(null, [], new WP_REST_Request('/wp/v2/users/me'));
|
||
|
||
self::assertFalse(RequestContext::isOAuthServerRequest());
|
||
}
|
||
|
||
public function testRecordRouteReturnsResponseUnchanged(): void
|
||
{
|
||
$response = new stdClass();
|
||
|
||
self::assertSame($response, RequestContext::recordRoute($response, [], new WP_REST_Request(self::ROUTE)));
|
||
}
|
||
|
||
public function testClearRouteResetsTheFlagAfterOurRequest(): void
|
||
{
|
||
$request = new WP_REST_Request(self::ROUTE);
|
||
RequestContext::recordRoute(null, [], $request);
|
||
$response = new stdClass();
|
||
|
||
$result = RequestContext::clearRoute($response, [], $request);
|
||
|
||
self::assertSame($response, $result);
|
||
self::assertFalse(RequestContext::isOAuthServerRequest());
|
||
}
|
||
|
||
public function testClearRouteIgnoresNestedRequestsToOtherRoutes(): void
|
||
{
|
||
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE));
|
||
|
||
RequestContext::clearRoute(null, [], new WP_REST_Request('/wp/v2/posts'));
|
||
|
||
self::assertTrue(RequestContext::isOAuthServerRequest());
|
||
}
|
||
|
||
public function testClientNameFallsBackToTheClientIdClaim(): void
|
||
{
|
||
Functions\when('get_option')->justReturn('test-secret');
|
||
$token = JWT::encode([
|
||
'sub' => '3',
|
||
'app_pass_id' => 'uuid-unknown',
|
||
'client_id' => 'https://claude.ai/oauth/mcp-oauth-client-metadata',
|
||
'exp' => time() + 60,
|
||
], 'test-secret');
|
||
|
||
RequestContext::recordRoute(
|
||
null,
|
||
[],
|
||
new WP_REST_Request(self::ROUTE, ['authorization' => 'Bearer ' . $token])
|
||
);
|
||
|
||
self::assertSame('https://claude.ai/oauth/mcp-oauth-client-metadata', RequestContext::clientName());
|
||
}
|
||
|
||
public function testClientNameIsEmptyForAnInvalidToken(): void
|
||
{
|
||
Functions\when('get_option')->justReturn('test-secret');
|
||
|
||
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE, ['authorization' => 'Bearer garbage']));
|
||
|
||
self::assertSame('', RequestContext::clientName());
|
||
}
|
||
|
||
public function testResetCanForceAClientName(): void
|
||
{
|
||
RequestContext::reset(true, 'Claude');
|
||
|
||
self::assertTrue(RequestContext::isOAuthServerRequest());
|
||
self::assertSame('Claude', RequestContext::clientName());
|
||
}
|
||
|
||
public function testRegisterHooksBeforeAndAfterCallbacks(): void
|
||
{
|
||
Functions\expect('add_filter')
|
||
->once()
|
||
->with('rest_request_before_callbacks', [RequestContext::class, 'recordRoute'], 10, 3);
|
||
Functions\expect('add_filter')
|
||
->once()
|
||
->with('rest_request_after_callbacks', [RequestContext::class, 'clearRoute'], 10, 3);
|
||
|
||
(new RequestContext())->register();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter RequestContextTest`
|
||
Expected: FAIL (`clearRoute`, `clientName` fehlen; `reset()` akzeptiert keinen zweiten Parameter).
|
||
|
||
- [ ] **Step 4: Implementierung**
|
||
|
||
`src/Server/RequestContext.php` (komplett ersetzen):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Server;
|
||
|
||
use Throwable;
|
||
use WP_REST_Request;
|
||
use WPMedia\MCP\OAuth\Auth\JWT;
|
||
use WPMedia\MCP\OAuth\Auth\SecretManager;
|
||
|
||
/**
|
||
* Tracks whether the current REST dispatch is serving our OAuth-protected MCP
|
||
* server route (`/mcp/mcp-oauth-server`) and which OAuth client made it.
|
||
*
|
||
* Abilities must never run outside that route. WordPress fires
|
||
* `rest_request_before_callbacks` for every matched REST route before any
|
||
* permission_callback runs and `rest_request_after_callbacks` right after the
|
||
* route callback returned (WP_REST_Server::respond_to_request()). The flag is
|
||
* set in the first and cleared in the second, so persistent PHP workers or a
|
||
* later `rest_do_request()` in the same process never inherit it.
|
||
*/
|
||
final class RequestContext
|
||
{
|
||
private static bool $isOAuthServerRequest = false;
|
||
private static ?string $bearerToken = null;
|
||
private static ?string $clientName = null;
|
||
|
||
public function register(): void
|
||
{
|
||
add_filter('rest_request_before_callbacks', [self::class, 'recordRoute'], 10, 3);
|
||
add_filter('rest_request_after_callbacks', [self::class, 'clearRoute'], 10, 3);
|
||
}
|
||
|
||
/**
|
||
* @param mixed $response
|
||
* @param array<string, mixed> $handler
|
||
* @return mixed
|
||
*/
|
||
public static function recordRoute(mixed $response, array $handler, WP_REST_Request $request): mixed
|
||
{
|
||
if ($request->get_route() === self::route()) {
|
||
self::$isOAuthServerRequest = true;
|
||
self::$clientName = null;
|
||
$header = $request->get_header('authorization');
|
||
self::$bearerToken = is_string($header) && str_starts_with($header, 'Bearer ')
|
||
? substr($header, 7)
|
||
: null;
|
||
}
|
||
|
||
return $response;
|
||
}
|
||
|
||
/**
|
||
* @param mixed $response
|
||
* @param array<string, mixed> $handler
|
||
* @return mixed
|
||
*/
|
||
public static function clearRoute(mixed $response, array $handler, WP_REST_Request $request): mixed
|
||
{
|
||
if ($request->get_route() === self::route()) {
|
||
self::reset();
|
||
}
|
||
|
||
return $response;
|
||
}
|
||
|
||
public static function isOAuthServerRequest(): bool
|
||
{
|
||
return self::$isOAuthServerRequest;
|
||
}
|
||
|
||
/**
|
||
* Name of the connected OAuth client (the Application Password mcp-oauth created
|
||
* for it), falling back to the token's client_id; '' when unknown.
|
||
*/
|
||
public static function clientName(): string
|
||
{
|
||
if (self::$clientName === null) {
|
||
self::$clientName = self::$bearerToken === null ? '' : self::resolveClientName(self::$bearerToken);
|
||
}
|
||
|
||
return self::$clientName;
|
||
}
|
||
|
||
/**
|
||
* Resets (or forces) the recorded state – after each request and between unit tests.
|
||
*/
|
||
public static function reset(bool $isOAuthServerRequest = false, ?string $clientName = null): void
|
||
{
|
||
self::$isOAuthServerRequest = $isOAuthServerRequest;
|
||
self::$bearerToken = null;
|
||
self::$clientName = $clientName;
|
||
}
|
||
|
||
private static function resolveClientName(string $token): string
|
||
{
|
||
try {
|
||
$claims = JWT::decode($token, SecretManager::get_secret());
|
||
if (! is_array($claims)) {
|
||
return '';
|
||
}
|
||
|
||
$userId = (int) ($claims['sub'] ?? 0);
|
||
$uuid = (string) ($claims['app_pass_id'] ?? '');
|
||
if ($userId > 0 && $uuid !== '' && class_exists('WP_Application_Passwords')) {
|
||
$item = \WP_Application_Passwords::get_user_application_password($userId, $uuid);
|
||
if (is_array($item) && is_string($item['name'] ?? null) && $item['name'] !== '') {
|
||
return $item['name'];
|
||
}
|
||
}
|
||
|
||
return (string) ($claims['client_id'] ?? '');
|
||
} catch (Throwable) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
private static function route(): string
|
||
{
|
||
return '/' . ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS (auch `AbilityRegistrarTest`, das `RequestContext::reset(true)` nutzt).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Server/RequestContext.php tests/Stubs/WpRestRequest.php tests/Unit/Server/RequestContextTest.php
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "fix: reset request context after the MCP request, resolve client name
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Änderungsprotokoll, Limits, Aktivierung/Deaktivierung, Cron
|
||
|
||
**Files:**
|
||
- Create: `src/Limits.php`, `src/ChangeLog/ChangeLog.php`, `src/ChangeLog/ChangeLogInstaller.php`, `src/ChangeLog/ChangeLogCleanup.php`, `src/Lifecycle.php`, `tests/Stubs/Wpdb.php`
|
||
- Modify: `wp-lit-mcp.php` (Hooks), `src/Plugin.php` (Boot), `tests/bootstrap.php`
|
||
- Test: `tests/Unit/LimitsTest.php`, `tests/Unit/ChangeLog/ChangeLogTest.php`, `tests/Unit/ChangeLog/ChangeLogInstallerTest.php`, `tests/Unit/ChangeLog/ChangeLogCleanupTest.php`, `tests/Unit/LifecycleTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `RequestContext::clientName()` (Task 2).
|
||
- Produces:
|
||
- `Limits` (final): `OPTION = 'lit_mcp_limits'`, `DEFAULTS = ['upload_mb' => 5, 'log_retention_days' => 90, 'snapshot_count' => 20]`; `uploadBytes(): int`, `uploadMegabytes(): int`, `logRetentionDays(): int`, `snapshotCount(): int`, `all(): array{upload_mb: int, log_retention_days: int, snapshot_count: int}`, `static sanitize(mixed $input): array` (für die Settings API in 1b).
|
||
- `ChangeLog` (nicht final, mockbar): `TABLE = 'lit_mcp_log'`; `__construct(?\wpdb $db = null)`; `tableName(): string`; `charsetCollate(): string`; `record(string $ability, string $objectType, string $objectId, string $summary, ?int $revisionId = null, ?string $snapshotId = null, string $result = 'ok'): ?int`; `purgeOlderThan(int $days): int`.
|
||
- `ChangeLogInstaller`: `VERSION_OPTION = 'lit_mcp_db_version'`, `VERSION = '1'`; `install(): void`, `maybeInstall(): void`.
|
||
- `ChangeLogCleanup`: `HOOK = 'lit_mcp_log_cleanup'`; `register(): void`, `ensureScheduled(): void`, `run(): void`.
|
||
- `Lifecycle::activate(): void`, `Lifecycle::deactivate(): void` (statisch).
|
||
- Stub `wpdb` für Unit-Tests (Eigenschaften `prefix`, `posts`, `postmeta`, `usermeta`, `options`, `insert_id`; echte `prepare()`/`esc_like()`), gemockt mit `Mockery::mock(\wpdb::class)->makePartial()`.
|
||
|
||
- [ ] **Step 1: wpdb-Stub**
|
||
|
||
`tests/Stubs/Wpdb.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! class_exists('wpdb')) {
|
||
/**
|
||
* Minimal wpdb for unit tests. prepare()/esc_like() behave like the real ones
|
||
* closely enough for assertions on the generated SQL; query methods are meant
|
||
* to be mocked via Mockery::mock(wpdb::class)->makePartial().
|
||
*/
|
||
class wpdb
|
||
{
|
||
public $prefix = 'wp_';
|
||
public $posts = 'wp_posts';
|
||
public $postmeta = 'wp_postmeta';
|
||
public $usermeta = 'wp_usermeta';
|
||
public $options = 'wp_options';
|
||
public $insert_id = 0;
|
||
|
||
public function prepare($query, ...$args)
|
||
{
|
||
if (count($args) === 1 && is_array($args[0])) {
|
||
$args = $args[0];
|
||
}
|
||
// Quotes string placeholders but does not escape, so tests can assert on raw values.
|
||
$query = str_replace("'%s'", '%s', $query);
|
||
$query = str_replace('%s', "'%s'", $query);
|
||
|
||
return vsprintf($query, $args);
|
||
}
|
||
|
||
public function esc_like($text)
|
||
{
|
||
return addcslashes($text, '_%\\');
|
||
}
|
||
|
||
public function get_charset_collate()
|
||
{
|
||
return 'DEFAULT CHARACTER SET utf8mb4';
|
||
}
|
||
|
||
public function insert($table, $data, $format = null)
|
||
{
|
||
return 1;
|
||
}
|
||
|
||
public function query($query)
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
public function get_col($query = null, $x = 0)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
public function get_var($query = null, $x = 0, $y = 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
public function get_results($query = null, $output = 'OBJECT')
|
||
{
|
||
return [];
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/bootstrap.php` (komplett ersetzen):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! defined('ABSPATH')) {
|
||
define('ABSPATH', __DIR__ . '/');
|
||
}
|
||
|
||
require_once __DIR__ . '/../vendor/autoload.php';
|
||
require_once __DIR__ . '/Stubs/WpError.php';
|
||
require_once __DIR__ . '/Stubs/WpUser.php';
|
||
require_once __DIR__ . '/Stubs/WpRestRequest.php';
|
||
require_once __DIR__ . '/Stubs/Wpdb.php';
|
||
```
|
||
|
||
- [ ] **Step 2: Failing Tests**
|
||
|
||
`tests/Unit/LimitsTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Limits;
|
||
|
||
final class LimitsTest extends TestCase
|
||
{
|
||
public function testDefaultsWhenOptionIsMissing(): void
|
||
{
|
||
Functions\when('get_option')->justReturn([]);
|
||
$limits = new Limits();
|
||
|
||
self::assertSame(5 * 1024 * 1024, $limits->uploadBytes());
|
||
self::assertSame(5, $limits->uploadMegabytes());
|
||
self::assertSame(90, $limits->logRetentionDays());
|
||
self::assertSame(20, $limits->snapshotCount());
|
||
}
|
||
|
||
public function testStoredValuesAreClamped(): void
|
||
{
|
||
Functions\when('get_option')->justReturn(['upload_mb' => 500, 'log_retention_days' => 0, 'snapshot_count' => '7']);
|
||
|
||
self::assertSame(['upload_mb' => 100, 'log_retention_days' => 1, 'snapshot_count' => 7], (new Limits())->all());
|
||
}
|
||
|
||
public function testCorruptOptionFallsBackToDefaults(): void
|
||
{
|
||
Functions\when('get_option')->justReturn('garbage');
|
||
|
||
self::assertSame(Limits::DEFAULTS, (new Limits())->all());
|
||
}
|
||
|
||
public function testSanitizeReturnsACompleteClampedArray(): void
|
||
{
|
||
self::assertSame(
|
||
['upload_mb' => 12, 'log_retention_days' => 90, 'snapshot_count' => 100],
|
||
Limits::sanitize(['upload_mb' => '12', 'log_retention_days' => 'x', 'snapshot_count' => 1000])
|
||
);
|
||
self::assertSame(Limits::DEFAULTS, Limits::sanitize(null));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/ChangeLog/ChangeLogTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Server\RequestContext;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ChangeLogTest extends TestCase
|
||
{
|
||
protected function tearDown(): void
|
||
{
|
||
RequestContext::reset();
|
||
parent::tearDown();
|
||
}
|
||
|
||
public function testRecordStoresReferencesAndReturnsTheId(): void
|
||
{
|
||
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
||
Functions\when('get_current_user_id')->justReturn(3);
|
||
RequestContext::reset(true, 'Claude');
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$db->expects('insert')
|
||
->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool {
|
||
return $row['created_at'] === '2026-09-25 08:00:00'
|
||
&& $row['user_id'] === 3
|
||
&& $row['client'] === 'Claude'
|
||
&& $row['ability'] === 'lit/content-update'
|
||
&& $row['object_type'] === 'page'
|
||
&& $row['object_id'] === '12'
|
||
&& $row['summary'] === 'Seite geändert'
|
||
&& $row['revision_id'] === 811
|
||
&& $row['snapshot_id'] === null
|
||
&& $row['result'] === 'ok';
|
||
}), Mockery::type('array'))
|
||
->andReturnUsing(static function () use ($db): int {
|
||
$db->insert_id = 4711;
|
||
return 1;
|
||
});
|
||
|
||
$id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'Seite geändert', 811);
|
||
|
||
self::assertSame(4711, $id);
|
||
}
|
||
|
||
public function testRecordReturnsNullWhenTheInsertFails(): void
|
||
{
|
||
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
||
Functions\when('get_current_user_id')->justReturn(3);
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$db->allows('insert')->andReturn(false);
|
||
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||
$previous = ini_set('error_log', $log);
|
||
|
||
try {
|
||
$id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'x');
|
||
} finally {
|
||
ini_set('error_log', (string) $previous);
|
||
}
|
||
|
||
self::assertNull($id);
|
||
self::assertStringContainsString('change log insert failed', (string) file_get_contents($log));
|
||
unlink($log);
|
||
}
|
||
|
||
public function testPurgeDeletesRowsOlderThanTheRetention(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$db->expects('query')
|
||
->with(Mockery::on(static fn (string $sql): bool => str_starts_with(
|
||
$sql,
|
||
"DELETE FROM wp_lit_mcp_log WHERE created_at < '"
|
||
)))
|
||
->andReturn(3);
|
||
|
||
self::assertSame(3, (new ChangeLog($db))->purgeOlderThan(90));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/ChangeLog/ChangeLogInstallerTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ChangeLogInstallerTest extends TestCase
|
||
{
|
||
public function testInstallCreatesTheTableAndStoresTheVersion(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
Functions\expect('dbDelta')->once()->with(Mockery::on(static function (string $sql): bool {
|
||
return str_contains($sql, 'CREATE TABLE wp_lit_mcp_log (')
|
||
&& str_contains($sql, 'client varchar(191)')
|
||
&& str_contains($sql, 'revision_id bigint(20) unsigned DEFAULT NULL')
|
||
&& str_contains($sql, 'snapshot_id varchar(64) DEFAULT NULL')
|
||
&& str_contains($sql, 'PRIMARY KEY (id)');
|
||
}));
|
||
Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1');
|
||
|
||
(new ChangeLogInstaller(new ChangeLog($db)))->install();
|
||
}
|
||
|
||
public function testMaybeInstallSkipsWhenTheVersionIsCurrent(): void
|
||
{
|
||
Functions\when('get_option')->justReturn('1');
|
||
Functions\expect('dbDelta')->never();
|
||
|
||
(new ChangeLogInstaller(new ChangeLog(Mockery::mock(\wpdb::class)->makePartial())))->maybeInstall();
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/ChangeLog/ChangeLogCleanupTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\ChangeLog;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||
use LIT\MCP\Limits;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ChangeLogCleanupTest extends TestCase
|
||
{
|
||
public function testRunPurgesWithTheConfiguredRetention(): void
|
||
{
|
||
Functions\when('get_option')->justReturn(['log_retention_days' => 30]);
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('purgeOlderThan')->with(30)->andReturn(0);
|
||
|
||
(new ChangeLogCleanup($log, new Limits()))->run();
|
||
}
|
||
|
||
public function testEnsureScheduledSchedulesADailyEventOnce(): void
|
||
{
|
||
Functions\when('wp_next_scheduled')->justReturn(false);
|
||
Functions\expect('wp_schedule_event')->once()->with(Mockery::type('int'), 'daily', 'lit_mcp_log_cleanup');
|
||
|
||
(new ChangeLogCleanup(Mockery::mock(ChangeLog::class), new Limits()))->ensureScheduled();
|
||
}
|
||
|
||
public function testEnsureScheduledDoesNothingWhenAlreadyScheduled(): void
|
||
{
|
||
Functions\when('wp_next_scheduled')->justReturn(1790330400);
|
||
Functions\expect('wp_schedule_event')->never();
|
||
|
||
(new ChangeLogCleanup(Mockery::mock(ChangeLog::class), new Limits()))->ensureScheduled();
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/LifecycleTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Lifecycle;
|
||
use Mockery;
|
||
|
||
final class LifecycleTest extends TestCase
|
||
{
|
||
protected function tearDown(): void
|
||
{
|
||
unset($GLOBALS['wpdb']);
|
||
parent::tearDown();
|
||
}
|
||
|
||
public function testActivateInstallsTheTableAndSchedulesTheCleanup(): void
|
||
{
|
||
$GLOBALS['wpdb'] = Mockery::mock(\wpdb::class)->makePartial();
|
||
Functions\expect('dbDelta')->once();
|
||
Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1');
|
||
Functions\when('wp_next_scheduled')->justReturn(false);
|
||
Functions\expect('wp_schedule_event')->once();
|
||
|
||
Lifecycle::activate();
|
||
}
|
||
|
||
public function testDeactivateUnschedulesTheCleanup(): void
|
||
{
|
||
Functions\expect('wp_clear_scheduled_hook')->once()->with('lit_mcp_log_cleanup');
|
||
|
||
Lifecycle::deactivate();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'LimitsTest|ChangeLog|LifecycleTest'`
|
||
Expected: FAIL, Klassen nicht gefunden.
|
||
|
||
- [ ] **Step 4: Implementierung**
|
||
|
||
`src/Limits.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP;
|
||
|
||
/**
|
||
* Admin-configurable limits (settings tab "Limits", spec §8.4).
|
||
*/
|
||
final class Limits
|
||
{
|
||
public const OPTION = 'lit_mcp_limits';
|
||
public const DEFAULTS = ['upload_mb' => 5, 'log_retention_days' => 90, 'snapshot_count' => 20];
|
||
private const BOUNDS = ['upload_mb' => [1, 100], 'log_retention_days' => [1, 3650], 'snapshot_count' => [1, 100]];
|
||
|
||
public function uploadBytes(): int
|
||
{
|
||
return $this->uploadMegabytes() * 1024 * 1024;
|
||
}
|
||
|
||
public function uploadMegabytes(): int
|
||
{
|
||
return $this->all()['upload_mb'];
|
||
}
|
||
|
||
public function logRetentionDays(): int
|
||
{
|
||
return $this->all()['log_retention_days'];
|
||
}
|
||
|
||
public function snapshotCount(): int
|
||
{
|
||
return $this->all()['snapshot_count'];
|
||
}
|
||
|
||
/** @return array{upload_mb: int, log_retention_days: int, snapshot_count: int} */
|
||
public function all(): array
|
||
{
|
||
return self::sanitize(get_option(self::OPTION, []));
|
||
}
|
||
|
||
/**
|
||
* Settings API sanitize callback: always returns all keys, clamped to their bounds.
|
||
*
|
||
* @return array{upload_mb: int, log_retention_days: int, snapshot_count: int}
|
||
*/
|
||
public static function sanitize(mixed $input): array
|
||
{
|
||
$input = is_array($input) ? $input : [];
|
||
$clean = self::DEFAULTS;
|
||
foreach (self::BOUNDS as $key => [$min, $max]) {
|
||
if (isset($input[$key]) && is_numeric($input[$key])) {
|
||
$clean[$key] = max($min, min($max, (int) $input[$key]));
|
||
}
|
||
}
|
||
|
||
return $clean;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/ChangeLog/ChangeLog.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\ChangeLog;
|
||
|
||
use LIT\MCP\Server\RequestContext;
|
||
|
||
/**
|
||
* Change log table {prefix}lit_mcp_log (spec §8.3). Stores references only,
|
||
* never content. Deliberately not final so abilities can be unit-tested with a mock.
|
||
*/
|
||
class ChangeLog
|
||
{
|
||
public const TABLE = 'lit_mcp_log';
|
||
|
||
public function __construct(private ?\wpdb $db = null)
|
||
{
|
||
}
|
||
|
||
public function tableName(): string
|
||
{
|
||
return $this->db()->prefix . self::TABLE;
|
||
}
|
||
|
||
public function charsetCollate(): string
|
||
{
|
||
return (string) $this->db()->get_charset_collate();
|
||
}
|
||
|
||
public function record(
|
||
string $ability,
|
||
string $objectType,
|
||
string $objectId,
|
||
string $summary,
|
||
?int $revisionId = null,
|
||
?string $snapshotId = null,
|
||
string $result = 'ok'
|
||
): ?int {
|
||
$wpdb = $this->db();
|
||
$inserted = $wpdb->insert(
|
||
$this->tableName(),
|
||
[
|
||
'created_at' => current_time('mysql', true),
|
||
'user_id' => get_current_user_id(),
|
||
'client' => mb_substr(RequestContext::clientName(), 0, 191),
|
||
'ability' => $ability,
|
||
'object_type' => $objectType,
|
||
'object_id' => $objectId,
|
||
'summary' => mb_substr($summary, 0, 500),
|
||
'revision_id' => $revisionId,
|
||
'snapshot_id' => $snapshotId,
|
||
'result' => $result,
|
||
],
|
||
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
|
||
);
|
||
|
||
if ($inserted === false) {
|
||
$message = sprintf('[wp-lit-mcp] change log insert failed for %s %s:%s', $ability, $objectType, $objectId);
|
||
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||
return null;
|
||
}
|
||
|
||
return (int) $wpdb->insert_id;
|
||
}
|
||
|
||
public function purgeOlderThan(int $days): int
|
||
{
|
||
$wpdb = $this->db();
|
||
$table = $this->tableName();
|
||
$cutoff = gmdate('Y-m-d H:i:s', time() - max(1, $days) * 86400);
|
||
|
||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is our own constant.
|
||
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff));
|
||
}
|
||
|
||
protected function db(): \wpdb
|
||
{
|
||
if ($this->db === null) {
|
||
global $wpdb;
|
||
$this->db = $wpdb;
|
||
}
|
||
|
||
return $this->db;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/ChangeLog/ChangeLogInstaller.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\ChangeLog;
|
||
|
||
/**
|
||
* Creates/updates the change log table via dbDelta. Runs on activation and –
|
||
* because plugin updates never fire the activation hook – whenever the stored
|
||
* schema version differs.
|
||
*/
|
||
final class ChangeLogInstaller
|
||
{
|
||
public const VERSION_OPTION = 'lit_mcp_db_version';
|
||
public const VERSION = '1';
|
||
|
||
public function __construct(private ChangeLog $log)
|
||
{
|
||
}
|
||
|
||
public function maybeInstall(): void
|
||
{
|
||
if (get_option(self::VERSION_OPTION) !== self::VERSION) {
|
||
$this->install();
|
||
}
|
||
}
|
||
|
||
public function install(): void
|
||
{
|
||
if (! function_exists('dbDelta')) {
|
||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||
}
|
||
|
||
$table = $this->log->tableName();
|
||
$charset = $this->log->charsetCollate();
|
||
|
||
dbDelta("CREATE TABLE {$table} (
|
||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||
created_at datetime NOT NULL,
|
||
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||
client varchar(191) NOT NULL DEFAULT '',
|
||
ability varchar(100) NOT NULL,
|
||
object_type varchar(50) NOT NULL DEFAULT '',
|
||
object_id varchar(191) NOT NULL DEFAULT '',
|
||
summary text NOT NULL,
|
||
revision_id bigint(20) unsigned DEFAULT NULL,
|
||
snapshot_id varchar(64) DEFAULT NULL,
|
||
result varchar(20) NOT NULL DEFAULT 'ok',
|
||
PRIMARY KEY (id),
|
||
KEY created_at (created_at),
|
||
KEY ability (ability),
|
||
KEY user_id (user_id)
|
||
) {$charset};");
|
||
|
||
update_option(self::VERSION_OPTION, self::VERSION);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/ChangeLog/ChangeLogCleanup.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\ChangeLog;
|
||
|
||
use LIT\MCP\Limits;
|
||
|
||
/**
|
||
* Daily WP-Cron job that removes log rows older than the configured retention.
|
||
*/
|
||
final class ChangeLogCleanup
|
||
{
|
||
public const HOOK = 'lit_mcp_log_cleanup';
|
||
|
||
public function __construct(private ChangeLog $log, private Limits $limits)
|
||
{
|
||
}
|
||
|
||
public function register(): void
|
||
{
|
||
add_action(self::HOOK, [$this, 'run']);
|
||
add_action('init', [$this, 'ensureScheduled']);
|
||
}
|
||
|
||
public function ensureScheduled(): void
|
||
{
|
||
if (wp_next_scheduled(self::HOOK) === false) {
|
||
wp_schedule_event(time() + 3600, 'daily', self::HOOK);
|
||
}
|
||
}
|
||
|
||
public function run(): void
|
||
{
|
||
$this->log->purgeOlderThan($this->limits->logRetentionDays());
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Lifecycle.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP;
|
||
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||
|
||
/**
|
||
* Activation and deactivation hooks (registered in wp-lit-mcp.php).
|
||
*/
|
||
final class Lifecycle
|
||
{
|
||
public static function activate(): void
|
||
{
|
||
$log = new ChangeLog();
|
||
(new ChangeLogInstaller($log))->install();
|
||
(new ChangeLogCleanup($log, new Limits()))->ensureScheduled();
|
||
}
|
||
|
||
public static function deactivate(): void
|
||
{
|
||
wp_clear_scheduled_hook(ChangeLogCleanup::HOOK);
|
||
}
|
||
}
|
||
```
|
||
|
||
`wp-lit-mcp.php`: direkt nach `require_once $lit_mcp_autoload;` einfügen:
|
||
```php
|
||
register_activation_hook(__FILE__, [\LIT\MCP\Lifecycle::class, 'activate']);
|
||
register_deactivation_hook(__FILE__, [\LIT\MCP\Lifecycle::class, 'deactivate']);
|
||
```
|
||
|
||
`src/Plugin.php`: in `boot()` direkt nach `(new RequestContext())->register();` einfügen:
|
||
```php
|
||
$changeLog = new ChangeLog();
|
||
(new ChangeLogInstaller($changeLog))->maybeInstall();
|
||
(new ChangeLogCleanup($changeLog, new Limits()))->register();
|
||
```
|
||
und oben die Imports ergänzen:
|
||
```php
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||
```
|
||
(`$changeLog` wird ab Task 6 an die `WordPressIntegration` übergeben.)
|
||
|
||
- [ ] **Step 5: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Integration – Tabelle und Cron in DDEV**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
timeout 120 ddev wp plugin deactivate WP-LIT-MCP && timeout 120 ddev wp plugin activate WP-LIT-MCP
|
||
timeout 120 ddev wp db query "SHOW COLUMNS FROM wp_lit_mcp_log" --skip-column-names
|
||
timeout 120 ddev wp cron event list --hook=lit_mcp_log_cleanup --fields=hook,recurrence
|
||
```
|
||
Expected: elf Spalten (`id` … `result`); ein Cron-Eintrag `lit_mcp_log_cleanup` mit Wiederholung `1 day`. (Tabellenpräfix der lokalen Installation ist `wp_`; falls `ddev wp db prefix` etwas anderes liefert, dieses verwenden.)
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/Limits.php src/Lifecycle.php src/ChangeLog src/Plugin.php wp-lit-mcp.php tests/Stubs/Wpdb.php tests/bootstrap.php tests/Unit/LimitsTest.php tests/Unit/LifecycleTest.php tests/Unit/ChangeLog
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: change log table with retention cron and limits option
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 4: Schema-Kern (`FieldDefinition`, Feldquellen, `ContentSchema`)
|
||
|
||
Spec §6.1: Post-Typen mit `show_ui`, Taxonomien je Typ, Felder zusammengeführt aus registrierten Meta-Schlüsseln, Pods (Task 5) und einer Stichprobe vorhandener Meta-Schlüssel (`_`-Schlüssel ausgeblendet, sofern keine Quelle sie kennt). Status je Feld: `writable`, `readonly`, `unknown`.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Schema/FieldDefinition.php`, `src/Integrations/WordPress/Schema/FieldSource.php`, `src/Integrations/WordPress/Schema/MetaFieldSource.php`, `src/Integrations/WordPress/Schema/RegisteredMetaSource.php`, `src/Integrations/WordPress/Schema/SampledMetaSource.php`, `src/Integrations/WordPress/Schema/ContentSchema.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php`, `tests/Unit/Integrations/WordPress/Schema/SampledMetaSourceTest.php`, `tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: wpdb-Stub (Task 3).
|
||
- Produces:
|
||
- `FieldDefinition` (final, readonly-Properties): `__construct(string $key, string $label, string $type, string $source, string $status, bool $required = false, bool $multiple = false, ?array $choices = null, ?array $related = null, string $description = '')`; Konstanten `WRITABLE`, `READONLY`, `UNKNOWN`; `toArray(): array{key, label, type, source, status, required, multiple, choices, related, description}`. Typ-Vokabular: `string`, `text`, `integer`, `number`, `boolean`, `date`, `datetime`, `time`, `choice`, `relation`, `file`, `array`, `object`, `unknown`. `choices`: `list<array{value: string, label: string}>|null`; `related`: `array{object_type: string, name: ?string}|null`.
|
||
- `FieldSource` (Interface): `id(): string`; `fieldsFor(string $postType): list<FieldDefinition>`; `read(int $postId, FieldDefinition $field): mixed`; `write(int $postId, string $postType, array $values, array $definitions): void` (`$values` key → Wert, `null` = leeren; `$definitions` key → FieldDefinition; wirft `RuntimeException`).
|
||
- `MetaFieldSource` (abstrakt, implementiert `read`/`write` über die Meta-API), `RegisteredMetaSource` (id `meta`), `SampledMetaSource` (id `sample`, `__construct(?\wpdb $db = null)`).
|
||
- `ContentSchema` (nicht final): `__construct(list<FieldSource> $sources, ?callable $diviEnabled = null)` (Quellen in Priorität, höchste zuerst); `postTypeNames(): list<string>` (sortiert, ohne `EXCLUDED`, Filter `lit_mcp_excluded_post_types`); `isManaged(string $postType): bool`; `describe(string $postType, bool $withFields): ?array`; `describePostTypes(): list<array>`; `fields(string $postType): array<string, FieldDefinition>` (nach Schlüssel sortiert, pro Request gecacht); `sourceOf(FieldDefinition $field): FieldSource`; `taxonomies(string $postType): list<array{name: string, label: string, hierarchical: bool}>`; `diviEnabled(string $postType): bool`.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class RegisteredMetaSourceTest extends TestCase
|
||
{
|
||
/** @return array<string, array<string, mixed>> */
|
||
private function fieldsByKey(): array
|
||
{
|
||
Functions\when('get_registered_meta_keys')->alias(static function (string $type, string $sub = ''): array {
|
||
if ($sub === '') {
|
||
return ['footnotes' => ['type' => 'string', 'single' => true, 'show_in_rest' => true]];
|
||
}
|
||
|
||
return [
|
||
'_internal' => ['type' => 'integer', 'single' => true, 'show_in_rest' => false],
|
||
'tags_list' => ['type' => 'array', 'single' => false, 'description' => 'Liste'],
|
||
];
|
||
});
|
||
Functions\when('is_protected_meta')->alias(static fn (string $key): bool => str_starts_with($key, '_'));
|
||
|
||
$fields = (new RegisteredMetaSource())->fieldsFor('event');
|
||
|
||
return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key');
|
||
}
|
||
|
||
public function testDescribesGlobalAndSubtypeMetaKeys(): void
|
||
{
|
||
$fields = $this->fieldsByKey();
|
||
|
||
self::assertSame(['footnotes', '_internal', 'tags_list'], array_keys($fields));
|
||
self::assertSame('string', $fields['footnotes']['type']);
|
||
self::assertSame(FieldDefinition::WRITABLE, $fields['footnotes']['status']);
|
||
self::assertSame('meta', $fields['footnotes']['source']);
|
||
self::assertFalse($fields['footnotes']['multiple']);
|
||
}
|
||
|
||
public function testProtectedMetaHiddenFromRestIsReadonly(): void
|
||
{
|
||
$fields = $this->fieldsByKey();
|
||
|
||
self::assertSame(FieldDefinition::READONLY, $fields['_internal']['status']);
|
||
self::assertSame('integer', $fields['_internal']['type']);
|
||
}
|
||
|
||
public function testNonSingleMetaIsMultiple(): void
|
||
{
|
||
$fields = $this->fieldsByKey();
|
||
|
||
self::assertTrue($fields['tags_list']['multiple']);
|
||
self::assertSame('Liste', $fields['tags_list']['description']);
|
||
}
|
||
|
||
public function testReadUsesSingleOrMultipleMeta(): void
|
||
{
|
||
Functions\expect('get_post_meta')->once()->with(5, 'footnotes', true)->andReturn('x');
|
||
Functions\expect('get_post_meta')->once()->with(5, 'tags_list', false)->andReturn(['a', 'b']);
|
||
$source = new RegisteredMetaSource();
|
||
|
||
self::assertSame('x', $source->read(5, new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable')));
|
||
self::assertSame(
|
||
['a', 'b'],
|
||
$source->read(5, new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true))
|
||
);
|
||
}
|
||
|
||
public function testWriteUpdatesReplacesListsAndDeletesNull(): void
|
||
{
|
||
$single = new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable');
|
||
$multi = new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true);
|
||
$gone = new FieldDefinition('old', 'old', 'string', 'meta', 'writable');
|
||
Functions\expect('update_post_meta')->once()->with(5, 'footnotes', 'neu');
|
||
Functions\expect('delete_post_meta')->once()->with(5, 'tags_list');
|
||
Functions\expect('add_post_meta')->once()->with(5, 'tags_list', 'a');
|
||
Functions\expect('add_post_meta')->once()->with(5, 'tags_list', 'b');
|
||
Functions\expect('delete_post_meta')->once()->with(5, 'old');
|
||
|
||
(new RegisteredMetaSource())->write(
|
||
5,
|
||
'event',
|
||
['footnotes' => 'neu', 'tags_list' => ['a', 'b'], 'old' => null],
|
||
['footnotes' => $single, 'tags_list' => $multi, 'old' => $gone]
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Schema/SampledMetaSourceTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
|
||
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class SampledMetaSourceTest extends TestCase
|
||
{
|
||
public function testReturnsVisibleKeysOfRecentPostsAsUnknown(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$db->expects('get_col')
|
||
->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, "post_type = 'lit_person'")
|
||
&& str_contains($sql, 'LIMIT 50')))
|
||
->andReturn(['_edit_lock', 'city', 'role']);
|
||
|
||
$fields = (new SampledMetaSource($db))->fieldsFor('lit_person');
|
||
|
||
self::assertSame(['city', 'role'], array_map(static fn (FieldDefinition $f): string => $f->key, $fields));
|
||
self::assertSame(FieldDefinition::UNKNOWN, $fields[0]->status);
|
||
self::assertSame('sample', $fields[0]->source);
|
||
self::assertSame('unknown', $fields[0]->type);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ContentSchemaTest extends TestCase
|
||
{
|
||
private function source(string $id, array $fields): FieldSource
|
||
{
|
||
$source = Mockery::mock(FieldSource::class);
|
||
$source->allows('id')->andReturn($id);
|
||
$source->allows('fieldsFor')->andReturn($fields);
|
||
|
||
return $source;
|
||
}
|
||
|
||
private function stubPostTypes(): void
|
||
{
|
||
Functions\when('get_post_types')->justReturn([
|
||
'post' => 'post',
|
||
'page' => 'page',
|
||
'attachment' => 'attachment',
|
||
'et_pb_layout' => 'et_pb_layout',
|
||
'lit_person' => 'lit_person',
|
||
]);
|
||
}
|
||
|
||
public function testPostTypeNamesExcludeInternalTypesAndAreSorted(): void
|
||
{
|
||
$this->stubPostTypes();
|
||
|
||
$schema = new ContentSchema([]);
|
||
|
||
self::assertSame(['lit_person', 'page', 'post'], $schema->postTypeNames());
|
||
self::assertTrue($schema->isManaged('page'));
|
||
self::assertFalse($schema->isManaged('et_pb_layout'));
|
||
}
|
||
|
||
public function testFieldsAreMergedByPriorityAndSorted(): void
|
||
{
|
||
$pods = $this->source('pods', [new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true)]);
|
||
$meta = $this->source('sample', [
|
||
new FieldDefinition('role', 'role', 'unknown', 'sample', 'unknown'),
|
||
new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
|
||
]);
|
||
$schema = new ContentSchema([$pods, $meta]);
|
||
|
||
$fields = $schema->fields('lit_person');
|
||
|
||
self::assertSame(['city', 'role'], array_keys($fields));
|
||
self::assertSame('pods', $fields['role']->source);
|
||
self::assertTrue($fields['role']->required);
|
||
self::assertSame($pods, $schema->sourceOf($fields['role']));
|
||
self::assertSame($meta, $schema->sourceOf($fields['city']));
|
||
}
|
||
|
||
public function testDescribeIncludesTaxonomiesCountsDiviAndOptionalFields(): void
|
||
{
|
||
$this->stubPostTypes();
|
||
Functions\when('get_post_type_object')->justReturn((object) [
|
||
'hierarchical' => true,
|
||
'labels' => (object) ['name' => 'Seiten', 'singular_name' => 'Seite'],
|
||
]);
|
||
Functions\when('get_all_post_type_supports')->justReturn(['title' => true, 'editor' => true]);
|
||
Functions\when('wp_count_posts')->justReturn((object) ['publish' => 3, 'draft' => 2, 'trash' => 9]);
|
||
Functions\when('get_object_taxonomies')->justReturn([
|
||
(object) ['name' => 'category', 'show_ui' => true, 'hierarchical' => true, 'labels' => (object) ['name' => 'Kategorien']],
|
||
(object) ['name' => 'post_format', 'show_ui' => false, 'hierarchical' => false, 'labels' => (object) ['name' => 'Formate']],
|
||
]);
|
||
$schema = new ContentSchema(
|
||
[$this->source('meta', [new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable')])],
|
||
static fn (string $type): bool => $type === 'page'
|
||
);
|
||
|
||
$withoutFields = $schema->describe('page', false);
|
||
$withFields = $schema->describe('page', true);
|
||
|
||
self::assertSame('Seiten', $withoutFields['label']);
|
||
self::assertSame('Seite', $withoutFields['singular_label']);
|
||
self::assertTrue($withoutFields['hierarchical']);
|
||
self::assertSame(['title', 'editor'], $withoutFields['supports']);
|
||
self::assertTrue($withoutFields['divi']);
|
||
self::assertSame(5, $withoutFields['count']);
|
||
self::assertSame([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]], $withoutFields['taxonomies']);
|
||
self::assertArrayNotHasKey('fields', $withoutFields);
|
||
self::assertSame('footnotes', $withFields['fields'][0]['key']);
|
||
self::assertNull($schema->describe('et_pb_layout', false));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'RegisteredMetaSourceTest|SampledMetaSourceTest|ContentSchemaTest'`
|
||
Expected: FAIL, Klassen nicht gefunden.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Schema/FieldDefinition.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
/**
|
||
* Description of one field of a post type, independent of where it comes from.
|
||
*/
|
||
final class FieldDefinition
|
||
{
|
||
public const WRITABLE = 'writable';
|
||
public const READONLY = 'readonly';
|
||
public const UNKNOWN = 'unknown';
|
||
|
||
/**
|
||
* @param list<array{value: string, label: string}>|null $choices
|
||
* @param array{object_type: string, name: ?string}|null $related
|
||
*/
|
||
public function __construct(
|
||
public readonly string $key,
|
||
public readonly string $label,
|
||
public readonly string $type,
|
||
public readonly string $source,
|
||
public readonly string $status,
|
||
public readonly bool $required = false,
|
||
public readonly bool $multiple = false,
|
||
public readonly ?array $choices = null,
|
||
public readonly ?array $related = null,
|
||
public readonly string $description = ''
|
||
) {
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function toArray(): array
|
||
{
|
||
return [
|
||
'key' => $this->key,
|
||
'label' => $this->label,
|
||
'type' => $this->type,
|
||
'source' => $this->source,
|
||
'status' => $this->status,
|
||
'required' => $this->required,
|
||
'multiple' => $this->multiple,
|
||
'choices' => $this->choices,
|
||
'related' => $this->related,
|
||
'description' => $this->description,
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Schema/FieldSource.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* A provider of field definitions for post types that can also read and write
|
||
* the values of its own fields (Pods via the Pods API, everything else via meta).
|
||
*/
|
||
interface FieldSource
|
||
{
|
||
public function id(): string;
|
||
|
||
/** @return list<FieldDefinition> */
|
||
public function fieldsFor(string $postType): array;
|
||
|
||
public function read(int $postId, FieldDefinition $field): mixed;
|
||
|
||
/**
|
||
* @param array<string, mixed> $values Validated values; null clears the field.
|
||
* @param array<string, FieldDefinition> $definitions All definitions of the post type, by key.
|
||
* @throws RuntimeException When the value could not be stored.
|
||
*/
|
||
public function write(int $postId, string $postType, array $values, array $definitions): void;
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Schema/MetaFieldSource.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
/**
|
||
* Shared read/write via the post meta API.
|
||
*/
|
||
abstract class MetaFieldSource implements FieldSource
|
||
{
|
||
public function read(int $postId, FieldDefinition $field): mixed
|
||
{
|
||
return get_post_meta($postId, $field->key, ! $field->multiple);
|
||
}
|
||
|
||
public function write(int $postId, string $postType, array $values, array $definitions): void
|
||
{
|
||
foreach ($values as $key => $value) {
|
||
$key = (string) $key;
|
||
if ($value === null) {
|
||
delete_post_meta($postId, $key);
|
||
continue;
|
||
}
|
||
|
||
$field = $definitions[$key] ?? null;
|
||
if ($field !== null && $field->multiple && is_array($value) && array_is_list($value)) {
|
||
delete_post_meta($postId, $key);
|
||
foreach ($value as $item) {
|
||
add_post_meta($postId, $key, wp_slash($item));
|
||
}
|
||
continue;
|
||
}
|
||
|
||
update_post_meta($postId, $key, wp_slash($value));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Schema/RegisteredMetaSource.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
/**
|
||
* Fields registered with register_meta()/register_post_meta().
|
||
*/
|
||
final class RegisteredMetaSource extends MetaFieldSource
|
||
{
|
||
private const TYPES = ['string', 'boolean', 'integer', 'number', 'array', 'object'];
|
||
|
||
public function id(): string
|
||
{
|
||
return 'meta';
|
||
}
|
||
|
||
public function fieldsFor(string $postType): array
|
||
{
|
||
$keys = array_merge(get_registered_meta_keys('post', ''), get_registered_meta_keys('post', $postType));
|
||
$fields = [];
|
||
foreach ($keys as $key => $args) {
|
||
$key = (string) $key;
|
||
$args = is_array($args) ? $args : [];
|
||
$type = (string) ($args['type'] ?? 'string');
|
||
$readonly = is_protected_meta($key, 'post') && empty($args['show_in_rest']);
|
||
|
||
$fields[] = new FieldDefinition(
|
||
$key,
|
||
$key,
|
||
in_array($type, self::TYPES, true) ? $type : 'unknown',
|
||
'meta',
|
||
$readonly ? FieldDefinition::READONLY : FieldDefinition::WRITABLE,
|
||
false,
|
||
! (bool) ($args['single'] ?? false),
|
||
null,
|
||
null,
|
||
(string) ($args['description'] ?? '')
|
||
);
|
||
}
|
||
|
||
return $fields;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Schema/SampledMetaSource.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
/**
|
||
* Meta keys actually used by the most recent posts of a type. Keys starting with
|
||
* "_" are internal and only shown when another source knows them.
|
||
*/
|
||
final class SampledMetaSource extends MetaFieldSource
|
||
{
|
||
private const SAMPLE_POSTS = 50;
|
||
private const MAX_KEYS = 200;
|
||
|
||
public function __construct(private ?\wpdb $db = null)
|
||
{
|
||
}
|
||
|
||
public function id(): string
|
||
{
|
||
return 'sample';
|
||
}
|
||
|
||
public function fieldsFor(string $postType): array
|
||
{
|
||
$wpdb = $this->db ?? $GLOBALS['wpdb'];
|
||
$keys = $wpdb->get_col($wpdb->prepare(
|
||
"SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm
|
||
INNER JOIN (
|
||
SELECT ID FROM {$wpdb->posts}
|
||
WHERE post_type = %s AND post_status NOT IN ('auto-draft', 'trash')
|
||
ORDER BY ID DESC LIMIT %d
|
||
) recent ON recent.ID = pm.post_id
|
||
ORDER BY pm.meta_key LIMIT %d",
|
||
$postType,
|
||
self::SAMPLE_POSTS,
|
||
self::MAX_KEYS
|
||
));
|
||
|
||
$fields = [];
|
||
foreach ((array) $keys as $key) {
|
||
$key = (string) $key;
|
||
if ($key === '' || str_starts_with($key, '_')) {
|
||
continue;
|
||
}
|
||
|
||
$fields[] = new FieldDefinition($key, $key, 'unknown', 'sample', FieldDefinition::UNKNOWN);
|
||
}
|
||
|
||
return $fields;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Schema/ContentSchema.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
use LogicException;
|
||
|
||
/**
|
||
* Detects the content model of this installation (spec §6.1): post types with a
|
||
* UI, their taxonomies and their fields merged from all field sources.
|
||
* Not final so abilities can be unit-tested with a mock.
|
||
*/
|
||
class ContentSchema
|
||
{
|
||
/** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
|
||
public const EXCLUDED = [
|
||
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
|
||
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
|
||
'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
|
||
'et_pb_canvas', '_et_pb_speculation',
|
||
];
|
||
|
||
private const COUNTED_STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
|
||
|
||
/** @var callable(string): bool */
|
||
private $diviEnabled;
|
||
|
||
/** @var array<string, array<string, FieldDefinition>> */
|
||
private array $fieldCache = [];
|
||
|
||
/**
|
||
* @param list<FieldSource> $sources Highest priority first.
|
||
*/
|
||
public function __construct(private array $sources, ?callable $diviEnabled = null)
|
||
{
|
||
$this->diviEnabled = $diviEnabled ?? static function (string $postType): bool {
|
||
return function_exists('et_builder_enabled_for_post_type')
|
||
&& (bool) et_builder_enabled_for_post_type($postType);
|
||
};
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function postTypeNames(): array
|
||
{
|
||
$excluded = apply_filters('lit_mcp_excluded_post_types', self::EXCLUDED);
|
||
$excluded = is_array($excluded) ? $excluded : self::EXCLUDED;
|
||
$names = array_values(array_diff(
|
||
array_map('strval', array_keys(get_post_types(['show_ui' => true], 'names'))),
|
||
$excluded
|
||
));
|
||
sort($names);
|
||
|
||
return $names;
|
||
}
|
||
|
||
public function isManaged(string $postType): bool
|
||
{
|
||
return in_array($postType, $this->postTypeNames(), true);
|
||
}
|
||
|
||
/** @return list<array<string, mixed>> */
|
||
public function describePostTypes(): array
|
||
{
|
||
$types = [];
|
||
foreach ($this->postTypeNames() as $name) {
|
||
$description = $this->describe($name, false);
|
||
if ($description !== null) {
|
||
$types[] = $description;
|
||
}
|
||
}
|
||
|
||
return $types;
|
||
}
|
||
|
||
/** @return array<string, mixed>|null Null when the post type is not managed. */
|
||
public function describe(string $postType, bool $withFields): ?array
|
||
{
|
||
$object = $this->isManaged($postType) ? get_post_type_object($postType) : null;
|
||
if ($object === null) {
|
||
return null;
|
||
}
|
||
|
||
$description = [
|
||
'name' => $postType,
|
||
'label' => (string) $object->labels->name,
|
||
'singular_label' => (string) $object->labels->singular_name,
|
||
'hierarchical' => (bool) $object->hierarchical,
|
||
'supports' => array_map('strval', array_keys(get_all_post_type_supports($postType))),
|
||
'divi' => $this->diviEnabled($postType),
|
||
'count' => $this->count($postType),
|
||
'taxonomies' => $this->taxonomies($postType),
|
||
];
|
||
|
||
if ($withFields) {
|
||
$description['fields'] = array_values(array_map(
|
||
static fn (FieldDefinition $field): array => $field->toArray(),
|
||
$this->fields($postType)
|
||
));
|
||
}
|
||
|
||
return $description;
|
||
}
|
||
|
||
/** @return array<string, FieldDefinition> */
|
||
public function fields(string $postType): array
|
||
{
|
||
if (! isset($this->fieldCache[$postType])) {
|
||
$merged = [];
|
||
foreach ($this->sources as $source) {
|
||
foreach ($source->fieldsFor($postType) as $field) {
|
||
$merged[$field->key] ??= $field;
|
||
}
|
||
}
|
||
ksort($merged);
|
||
$this->fieldCache[$postType] = $merged;
|
||
}
|
||
|
||
return $this->fieldCache[$postType];
|
||
}
|
||
|
||
public function sourceOf(FieldDefinition $field): FieldSource
|
||
{
|
||
foreach ($this->sources as $source) {
|
||
if ($source->id() === $field->source) {
|
||
return $source;
|
||
}
|
||
}
|
||
|
||
throw new LogicException(sprintf('No field source "%s" registered.', $field->source));
|
||
}
|
||
|
||
/** @return list<array{name: string, label: string, hierarchical: bool}> */
|
||
public function taxonomies(string $postType): array
|
||
{
|
||
$result = [];
|
||
foreach (get_object_taxonomies($postType, 'objects') as $taxonomy) {
|
||
if (! $taxonomy->show_ui) {
|
||
continue;
|
||
}
|
||
|
||
$result[] = [
|
||
'name' => (string) $taxonomy->name,
|
||
'label' => (string) $taxonomy->labels->name,
|
||
'hierarchical' => (bool) $taxonomy->hierarchical,
|
||
];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
public function diviEnabled(string $postType): bool
|
||
{
|
||
return ($this->diviEnabled)($postType);
|
||
}
|
||
|
||
private function count(string $postType): int
|
||
{
|
||
$counts = (array) wp_count_posts($postType);
|
||
$total = 0;
|
||
foreach (self::COUNTED_STATUSES as $status) {
|
||
$total += (int) ($counts[$status] ?? 0);
|
||
}
|
||
|
||
return $total;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS. Falls phpcs in `SampledMetaSource` `WordPress.DB.PreparedSQL` meldet, obwohl nur `$wpdb->`-Eigenschaften interpoliert sind: die Zeile mit `$wpdb->get_col(` mit `// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only core table names interpolated.` versehen.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress/Schema tests/Unit/Integrations/WordPress/Schema
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: content schema with registered and sampled meta fields
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Pods-Feldquelle
|
||
|
||
Liest Felddefinitionen über `pods_api()->load_pod()` (Pods 3.x: `Pods\Whatsit\Pod`/`Field`) und schreibt Werte über `pods($type, $id)->save()` – damit Pods Beziehungen, Caches und Hooks selbst pflegt (Spec §6.2).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Pods/PodsFieldSource.php`, `tests/Integration/pods-fixture.php`
|
||
- Test: `tests/Unit/Integrations/Pods/PodsFieldSourceTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `FieldSource`, `FieldDefinition` (Task 4).
|
||
- Produces: `PodsFieldSource implements FieldSource` (id `pods`): `__construct(?callable $loadPod = null, ?callable $podFor = null)` (Standard: `pods_api()->load_pod(['name' => $type])` bzw. `pods($type, $id)`); `static isAvailable(): bool`. Typ-Abbildung: `text|website|phone|email|password|slug|color|oembed` → `string`, `paragraph|wysiwyg|code` → `text`, `number|currency` → `number`, `boolean`, `date`, `datetime`, `time`, `file|avatar` → `file` (related `media`), `pick` mit `pick_object = custom-simple` → `choice` (Auswahl aus `pick_custom`, Zeilen `wert|Label`), sonstiges `pick` → `relation` (related `object_type`/`name` aus `get_related_object_type()`/`get_related_object_name()`); Layout-Felder `heading`, `html` werden ausgelassen; unbekannte Typen → `unknown`/`UNKNOWN`. Integrationshelfer `tests/Integration/pods-fixture.php create|delete` legt den Pods-Post-Typ `lit_person` (Felder `role` Text Pflicht, `level` Auswahl `junior|senior`, `mentor` Beziehung auf `lit_person`) an bzw. entfernt ihn.
|
||
|
||
- [ ] **Step 1: Failing Test**
|
||
|
||
`tests/Unit/Integrations/Pods/PodsFieldSourceTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use Exception;
|
||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
|
||
final class PodsFieldSourceTest extends TestCase
|
||
{
|
||
/** @param array<string, mixed> $args */
|
||
private function field(string $name, string $type, array $args = [], array $extra = []): object
|
||
{
|
||
$field = Mockery::mock();
|
||
$field->allows('get_name')->andReturn($name);
|
||
$field->allows('get_label')->andReturn(ucfirst($name));
|
||
$field->allows('get_type')->andReturn($type);
|
||
$field->allows('is_required')->andReturn($extra['required'] ?? false);
|
||
$field->allows('is_multi_value')->andReturn($extra['multi'] ?? false);
|
||
$field->allows('get_related_object_type')->andReturn($extra['related_type'] ?? null);
|
||
$field->allows('get_related_object_name')->andReturn($extra['related_name'] ?? null);
|
||
$field->allows('get_arg')->andReturnUsing(static fn (string $key, $default = null) => $args[$key] ?? $default);
|
||
|
||
return $field;
|
||
}
|
||
|
||
private function source(?object $pod): PodsFieldSource
|
||
{
|
||
return new PodsFieldSource(static fn (string $name) => $pod ?? false);
|
||
}
|
||
|
||
/** @return array<string, array<string, mixed>> */
|
||
private function describe(): array
|
||
{
|
||
$pod = Mockery::mock();
|
||
$pod->allows('get_type')->andReturn('post_type');
|
||
$pod->allows('get_fields')->andReturn([
|
||
'role' => $this->field('role', 'text', ['description' => 'Funktion im Team'], ['required' => true]),
|
||
'level' => $this->field('level', 'pick', [
|
||
'pick_object' => 'custom-simple',
|
||
'pick_custom' => "junior|Junior\nsenior|Senior\nplain",
|
||
]),
|
||
'mentor' => $this->field('mentor', 'pick', ['pick_object' => 'post_type'], [
|
||
'related_type' => 'post_type',
|
||
'related_name' => 'lit_person',
|
||
]),
|
||
'photos' => $this->field('photos', 'file', [], ['multi' => true]),
|
||
'intro' => $this->field('intro', 'heading'),
|
||
'mystery' => $this->field('mystery', 'fancy-addon-type'),
|
||
]);
|
||
|
||
$fields = $this->source($pod)->fieldsFor('lit_person');
|
||
|
||
return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key');
|
||
}
|
||
|
||
public function testMapsPodsFieldTypes(): void
|
||
{
|
||
$fields = $this->describe();
|
||
|
||
self::assertSame(['role', 'level', 'mentor', 'photos', 'mystery'], array_keys($fields));
|
||
self::assertSame('string', $fields['role']['type']);
|
||
self::assertTrue($fields['role']['required']);
|
||
self::assertSame('Funktion im Team', $fields['role']['description']);
|
||
self::assertSame('pods', $fields['role']['source']);
|
||
self::assertSame(FieldDefinition::WRITABLE, $fields['role']['status']);
|
||
self::assertSame('relation', $fields['mentor']['type']);
|
||
self::assertSame(['object_type' => 'post_type', 'name' => 'lit_person'], $fields['mentor']['related']);
|
||
self::assertSame('file', $fields['photos']['type']);
|
||
self::assertTrue($fields['photos']['multiple']);
|
||
self::assertSame(['object_type' => 'media', 'name' => null], $fields['photos']['related']);
|
||
self::assertSame('unknown', $fields['mystery']['type']);
|
||
self::assertSame(FieldDefinition::UNKNOWN, $fields['mystery']['status']);
|
||
}
|
||
|
||
public function testParsesCustomSimpleChoices(): void
|
||
{
|
||
self::assertSame(
|
||
[
|
||
['value' => 'junior', 'label' => 'Junior'],
|
||
['value' => 'senior', 'label' => 'Senior'],
|
||
['value' => 'plain', 'label' => 'plain'],
|
||
],
|
||
$this->describe()['level']['choices']
|
||
);
|
||
}
|
||
|
||
public function testIgnoresNonPodsAndNonPostTypePods(): void
|
||
{
|
||
$taxonomyPod = Mockery::mock();
|
||
$taxonomyPod->allows('get_type')->andReturn('taxonomy');
|
||
|
||
self::assertSame([], $this->source(null)->fieldsFor('page'));
|
||
self::assertSame([], $this->source($taxonomyPod)->fieldsFor('genre'));
|
||
}
|
||
|
||
public function testReadsRelationsAsIdsAndOtherFieldsRaw(): void
|
||
{
|
||
Functions\when('get_post_type')->justReturn('lit_person');
|
||
$pod = Mockery::mock();
|
||
$pod->expects('field')->with(['name' => 'mentor', 'output' => 'ids', 'single' => true])->andReturn('12');
|
||
$pod->expects('field')->with(['name' => 'photos', 'output' => 'ids', 'single' => false])->andReturn(['3', '4']);
|
||
$pod->expects('field')->with(['name' => 'role', 'single' => true, 'raw' => true])->andReturn('Leitung');
|
||
$source = new PodsFieldSource(null, static fn (string $type, int $id) => $pod);
|
||
|
||
self::assertSame(12, $source->read(5, new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable')));
|
||
self::assertSame(
|
||
[3, 4],
|
||
$source->read(5, new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true))
|
||
);
|
||
self::assertSame('Leitung', $source->read(5, new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')));
|
||
}
|
||
|
||
public function testWriteSavesThroughPodsAndMapsNullToEmpty(): void
|
||
{
|
||
$definitions = [
|
||
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
|
||
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true),
|
||
];
|
||
$pod = Mockery::mock();
|
||
$pod->expects('save')
|
||
->with(['role' => '', 'photos' => []], null, null, ['error_mode' => 'exception'])
|
||
->andReturn(5);
|
||
|
||
(new PodsFieldSource(null, static fn () => $pod))
|
||
->write(5, 'lit_person', ['role' => null, 'photos' => null], $definitions);
|
||
}
|
||
|
||
public function testWriteFailureBecomesRuntimeException(): void
|
||
{
|
||
$pod = Mockery::mock();
|
||
$pod->allows('save')->andThrow(new Exception('Pods error'));
|
||
|
||
$this->expectException(RuntimeException::class);
|
||
|
||
(new PodsFieldSource(null, static fn () => $pod))->write(5, 'lit_person', ['role' => 'x'], []);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter PodsFieldSourceTest`
|
||
Expected: FAIL, Klasse nicht gefunden.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/Pods/PodsFieldSource.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Pods;
|
||
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Pods field adapter (spec §6.1/§6.2): definitions from pods_api()->load_pod(),
|
||
* values read and written through the Pods API so relationships and caches stay
|
||
* consistent. Works with Pods 3.x (Pods\Whatsit\Pod / Pods\Whatsit\Field).
|
||
*/
|
||
final class PodsFieldSource implements FieldSource
|
||
{
|
||
private const TYPES = [
|
||
'text' => 'string', 'website' => 'string', 'phone' => 'string', 'email' => 'string',
|
||
'password' => 'string', 'slug' => 'string', 'color' => 'string', 'oembed' => 'string',
|
||
'paragraph' => 'text', 'wysiwyg' => 'text', 'code' => 'text',
|
||
'number' => 'number', 'currency' => 'number',
|
||
'boolean' => 'boolean',
|
||
'date' => 'date', 'datetime' => 'datetime', 'time' => 'time',
|
||
'file' => 'file', 'avatar' => 'file',
|
||
];
|
||
private const LAYOUT_TYPES = ['heading', 'html'];
|
||
private const ID_TYPES = ['relation', 'file'];
|
||
|
||
/** @var callable(string): mixed */
|
||
private $loadPod;
|
||
|
||
/** @var callable(string, int): mixed */
|
||
private $podFor;
|
||
|
||
public function __construct(?callable $loadPod = null, ?callable $podFor = null)
|
||
{
|
||
$this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]);
|
||
$this->podFor = $podFor ?? static fn (string $name, int $id): mixed => pods($name, $id);
|
||
}
|
||
|
||
public static function isAvailable(): bool
|
||
{
|
||
return function_exists('pods_api') && function_exists('pods');
|
||
}
|
||
|
||
public function id(): string
|
||
{
|
||
return 'pods';
|
||
}
|
||
|
||
public function fieldsFor(string $postType): array
|
||
{
|
||
$pod = ($this->loadPod)($postType);
|
||
if (! is_object($pod) || $pod->get_type() !== 'post_type') {
|
||
return [];
|
||
}
|
||
|
||
$fields = [];
|
||
foreach ($pod->get_fields() as $field) {
|
||
$type = (string) $field->get_type();
|
||
if (in_array($type, self::LAYOUT_TYPES, true)) {
|
||
continue;
|
||
}
|
||
|
||
$fields[] = $this->definition($field, $type);
|
||
}
|
||
|
||
return $fields;
|
||
}
|
||
|
||
public function read(int $postId, FieldDefinition $field): mixed
|
||
{
|
||
$pod = ($this->podFor)((string) get_post_type($postId), $postId);
|
||
if (! is_object($pod)) {
|
||
return null;
|
||
}
|
||
|
||
if (in_array($field->type, self::ID_TYPES, true)) {
|
||
$ids = $pod->field(['name' => $field->key, 'output' => 'ids', 'single' => ! $field->multiple]);
|
||
if ($field->multiple) {
|
||
return array_values(array_map('intval', array_filter((array) $ids, 'is_numeric')));
|
||
}
|
||
|
||
return is_numeric($ids) ? (int) $ids : null;
|
||
}
|
||
|
||
return $pod->field(['name' => $field->key, 'single' => true, 'raw' => true]);
|
||
}
|
||
|
||
public function write(int $postId, string $postType, array $values, array $definitions): void
|
||
{
|
||
$data = [];
|
||
foreach ($values as $key => $value) {
|
||
$multiple = isset($definitions[$key]) && $definitions[$key]->multiple;
|
||
$data[$key] = $value ?? ($multiple ? [] : '');
|
||
}
|
||
|
||
$pod = ($this->podFor)($postType, $postId);
|
||
if (! is_object($pod)) {
|
||
throw new RuntimeException(sprintf('Pods object for %s #%d is not available.', $postType, $postId));
|
||
}
|
||
|
||
try {
|
||
$result = $pod->save($data, null, null, ['error_mode' => 'exception']);
|
||
} catch (Throwable $e) {
|
||
throw new RuntimeException('Pods could not save the fields: ' . $e->getMessage(), 0, $e);
|
||
}
|
||
|
||
if ($result === false || is_wp_error($result)) {
|
||
throw new RuntimeException('Pods could not save the fields.');
|
||
}
|
||
}
|
||
|
||
private function definition(object $field, string $type): FieldDefinition
|
||
{
|
||
$normalized = self::TYPES[$type] ?? 'unknown';
|
||
$choices = null;
|
||
$related = null;
|
||
|
||
if ($type === 'pick') {
|
||
if ((string) $field->get_arg('pick_object', '') === 'custom-simple') {
|
||
$normalized = 'choice';
|
||
$choices = $this->choices((string) $field->get_arg('pick_custom', ''));
|
||
} else {
|
||
$normalized = 'relation';
|
||
$name = $field->get_related_object_name();
|
||
$related = [
|
||
'object_type' => (string) $field->get_related_object_type(),
|
||
'name' => $name === null ? null : (string) $name,
|
||
];
|
||
}
|
||
} elseif ($normalized === 'file') {
|
||
$related = ['object_type' => 'media', 'name' => null];
|
||
}
|
||
|
||
return new FieldDefinition(
|
||
(string) $field->get_name(),
|
||
(string) $field->get_label(),
|
||
$normalized,
|
||
'pods',
|
||
$normalized === 'unknown' ? FieldDefinition::UNKNOWN : FieldDefinition::WRITABLE,
|
||
(bool) $field->is_required(),
|
||
(bool) $field->is_multi_value(),
|
||
$choices,
|
||
$related,
|
||
(string) $field->get_arg('description', '')
|
||
);
|
||
}
|
||
|
||
/** @return list<array{value: string, label: string}> */
|
||
private function choices(string $raw): array
|
||
{
|
||
$choices = [];
|
||
foreach (preg_split('/\r\n|\r|\n/', trim($raw)) ?: [] as $line) {
|
||
$line = trim(trim($line), '|');
|
||
if ($line === '') {
|
||
continue;
|
||
}
|
||
|
||
$parts = explode('|', $line, 2);
|
||
$value = trim($parts[0]);
|
||
$choices[] = ['value' => $value, 'label' => trim($parts[1] ?? $value)];
|
||
}
|
||
|
||
return $choices;
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Integration/pods-fixture.php`:
|
||
```php
|
||
<?php
|
||
|
||
/**
|
||
* Creates or removes the Pods test content type "lit_person" for integration tests.
|
||
*
|
||
* Usage (inside DDEV, Pods must be active):
|
||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create
|
||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php delete
|
||
*
|
||
* No strict_types: wp eval-file runs this through eval().
|
||
*/
|
||
|
||
defined('WP_CLI') && WP_CLI || exit;
|
||
|
||
if (! function_exists('pods_api')) {
|
||
fwrite(STDERR, "Pods is not active\n");
|
||
exit(1);
|
||
}
|
||
|
||
$action = $args[0] ?? 'create';
|
||
$api = pods_api();
|
||
|
||
if ($action === 'delete') {
|
||
if ($api->load_pod(['name' => 'lit_person'])) {
|
||
$api->delete_pod(['name' => 'lit_person'], false, true);
|
||
}
|
||
echo "deleted\n";
|
||
return;
|
||
}
|
||
|
||
if (! $api->load_pod(['name' => 'lit_person'])) {
|
||
$api->save_pod([
|
||
'name' => 'lit_person',
|
||
'label' => 'Personen',
|
||
'label_singular' => 'Person',
|
||
'type' => 'post_type',
|
||
'storage' => 'meta',
|
||
'public' => 1,
|
||
'show_ui' => 1,
|
||
]);
|
||
}
|
||
|
||
$fields = [
|
||
['name' => 'role', 'label' => 'Funktion', 'type' => 'text', 'required' => 1],
|
||
[
|
||
'name' => 'level',
|
||
'label' => 'Stufe',
|
||
'type' => 'pick',
|
||
'pick_object' => 'custom-simple',
|
||
'pick_custom' => "junior|Junior\nsenior|Senior",
|
||
],
|
||
[
|
||
'name' => 'mentor',
|
||
'label' => 'Mentor',
|
||
'type' => 'pick',
|
||
'pick_object' => 'post_type',
|
||
'pick_val' => 'lit_person',
|
||
'pick_format_type' => 'single',
|
||
],
|
||
];
|
||
foreach ($fields as $field) {
|
||
$api->save_field(array_merge(['pod' => 'lit_person'], $field));
|
||
}
|
||
|
||
echo "created\n";
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Integration mit echtem Pods**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
timeout 120 ddev wp plugin activate pods
|
||
timeout 120 ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create
|
||
timeout 120 ddev wp eval '
|
||
$s = new \LIT\MCP\Integrations\WordPress\Schema\ContentSchema([
|
||
new \LIT\MCP\Integrations\Pods\PodsFieldSource(),
|
||
new \LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource(),
|
||
new \LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource(),
|
||
]);
|
||
echo wp_json_encode(array_map(static fn ($f) => $f->toArray(), $s->fields("lit_person")), JSON_PRETTY_PRINT);'
|
||
timeout 120 ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php delete
|
||
timeout 120 ddev wp plugin deactivate pods
|
||
```
|
||
Expected: JSON mit `role` (`type` `string`, `required` true), `level` (`type` `choice`, zwei `choices`), `mentor` (`type` `relation`, `related.name` `lit_person`), alle mit `source` `pods`. Weicht die Typ- oder Beziehungsabbildung ab, `definition()` an die tatsächliche Pods-Ausgabe anpassen und einen Unit-Test für den Fall ergänzen, bevor weitergemacht wird.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Pods tests/Unit/Integrations/Pods tests/Integration/pods-fixture.php
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Pods field source for schema detection and writes
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 6: `lit/content-types`, `WordPressIntegration`, Boot-Verdrahtung, Server-Instructions
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/WordPressIntegration.php`, `src/Integrations/WordPress/Content/ContentTypesAbility.php`
|
||
- Modify: `src/Plugin.php` (komplett ersetzen), `src/Guides/InstructionsBuilder.php` (komplett ersetzen)
|
||
- Test: `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`, `tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php` (Test ergänzen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ContentSchema`, Feldquellen (Tasks 4–5), `ChangeLog`, `ChangeLogInstaller`, `ChangeLogCleanup`, `Limits` (Task 3), `Ability`, `Errors`, `Integration`, `IntegrationRegistry` (Etappe 0).
|
||
- Produces:
|
||
- `WordPressIntegration implements Integration`: `__construct(ContentSchema $schema, ChangeLog $log)`, `id()` = `wordpress`, immer verfügbar; `abilities()` wächst in den Tasks 8–15 (jeder Task gibt die vollständige neue Methode an).
|
||
- `ContentTypesAbility`: `__construct(ContentSchema $schema)`; Name `lit/content-types`; Input `{post_type?: string}`; Output `{post_types: list<object>}`; direkt; readonly, idempotent.
|
||
- `InstructionsBuilder::__construct(IntegrationRegistry $registry, ?callable $postTypeNames = null)` – mit Callable zusätzliche Zeile `Inhaltstypen: … (Felder und Taxonomien: lit-content-types).`
|
||
- `Plugin::boot()` registriert die `WordPressIntegration`; `Plugin::contentSchema(): ContentSchema` (privat) baut die Quellen: Pods (falls `PodsFieldSource::isAvailable()`), registrierte Meta, Stichprobe.
|
||
- Hook-Vertrag für spätere Etappen: Action `lit_mcp_after_save` mit `(int $postId, string $ability, array $input)` – wird ab Task 9 nach jedem Schreiben eines Eintrags ausgelöst (Spec §6.2).
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Error;
|
||
|
||
final class ContentTypesAbilityTest extends TestCase
|
||
{
|
||
public function testListsAllTypesWithoutFields(): void
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->expects('describePostTypes')->andReturn([['name' => 'page'], ['name' => 'post']]);
|
||
|
||
$result = (new ContentTypesAbility($schema))->execute([]);
|
||
|
||
self::assertSame(['post_types' => [['name' => 'page'], ['name' => 'post']]], $result);
|
||
}
|
||
|
||
public function testSingleTypeIncludesFields(): void
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->expects('describe')->with('lit_person', true)->andReturn(['name' => 'lit_person', 'fields' => []]);
|
||
|
||
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'lit_person']);
|
||
|
||
self::assertSame([['name' => 'lit_person', 'fields' => []]], $result['post_types']);
|
||
}
|
||
|
||
public function testUnknownTypeIsNotFoundWithTheAvailableTypes(): void
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('describe')->andReturn(null);
|
||
$schema->allows('postTypeNames')->andReturn(['page', 'post']);
|
||
|
||
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'nope']);
|
||
|
||
self::assertInstanceOf(WP_Error::class, $result);
|
||
self::assertSame('lit_not_found', $result->get_error_code());
|
||
self::assertStringContainsString('page, post', $result->get_error_message());
|
||
}
|
||
|
||
public function testPermissionRequiresEditPosts(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
$ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class));
|
||
|
||
self::assertSame('lit_forbidden', $ability->permission([])->get_error_code());
|
||
}
|
||
|
||
public function testMetadata(): void
|
||
{
|
||
$ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class));
|
||
|
||
self::assertSame('lit/content-types', $ability->name());
|
||
self::assertTrue($ability->isDirect());
|
||
self::assertSame(['readonly' => true, 'destructive' => false, 'idempotent' => true], $ability->annotations());
|
||
self::assertFalse($ability->inputSchema()['additionalProperties']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class WordPressIntegrationTest extends TestCase
|
||
{
|
||
/** Grows with every task that adds abilities – keep in the order of abilities(). */
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
];
|
||
|
||
public function testProvidesTheWordPressAbilitiesInOrder(): void
|
||
{
|
||
$integration = new WordPressIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));
|
||
|
||
self::assertSame('wordpress', $integration->id());
|
||
self::assertTrue($integration->isAvailable());
|
||
self::assertSame(
|
||
self::EXPECTED,
|
||
array_map(static fn (Ability $ability): string => $ability->name(), $integration->abilities())
|
||
);
|
||
}
|
||
|
||
public function testEveryAbilityHasAnObjectInputSchemaThatRejectsUnknownKeys(): void
|
||
{
|
||
$integration = new WordPressIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));
|
||
|
||
foreach ($integration->abilities() as $ability) {
|
||
$schema = $ability->inputSchema();
|
||
self::assertSame('object', $schema['type'], $ability->name());
|
||
self::assertFalse($schema['additionalProperties'], $ability->name());
|
||
self::assertMatchesRegularExpression('#^lit/[a-z]+(-[a-z]+)+$#', $ability->name());
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
In `tests/Unit/Guides/InstructionsBuilderTest.php` diese Methode ergänzen:
|
||
```php
|
||
public function testListsPostTypesWhenAProviderIsGiven(): void
|
||
{
|
||
$core = Mockery::mock(Integration::class);
|
||
$core->allows('id')->andReturn('core');
|
||
$core->allows('isAvailable')->andReturn(true);
|
||
|
||
$text = (new InstructionsBuilder(new IntegrationRegistry($core), static fn (): array => ['page', 'post']))->build();
|
||
|
||
self::assertStringContainsString('Inhaltstypen: page, post', $text);
|
||
self::assertStringContainsString('lit-content-types', $text);
|
||
self::assertStringContainsString('confirm: true', $text);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'ContentTypesAbilityTest|WordPressIntegrationTest|InstructionsBuilderTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Content/ContentTypesAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
|
||
final class ContentTypesAbility implements Ability
|
||
{
|
||
public function __construct(private ContentSchema $schema)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/content-types';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Inhaltstypen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Listet die Inhaltstypen (Post-Typen) dieser Website mit Taxonomien, Anzahl und Divi-Fähigkeit. '
|
||
. 'Mit post_type zusätzlich alle Felder (Pods, WordPress-Meta) mit Typ, Pflicht, Auswahlwerten, '
|
||
. 'Beziehungsziel und Schreibstatus (writable/readonly/unknown).';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_type' => [
|
||
'type' => 'string',
|
||
'description' => 'Optional: nur diesen Inhaltstyp, dafür mit allen Feldern.',
|
||
],
|
||
],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_types' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
],
|
||
'required' => ['post_types'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return current_user_can('edit_posts')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : '';
|
||
if ($postType === '') {
|
||
return ['post_types' => $this->schema->describePostTypes()];
|
||
}
|
||
|
||
$description = $this->schema->describe($postType, true);
|
||
if ($description === null) {
|
||
return Errors::notFound(
|
||
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $postType),
|
||
'Verfügbare Inhaltstypen: ' . implode(', ', $this->schema->postTypeNames()) . '.'
|
||
);
|
||
}
|
||
|
||
return ['post_types' => [$description]];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress;
|
||
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
|
||
/**
|
||
* WordPress basics (spec §6): content, status, copies, revisions, menus, settings.
|
||
* Always available.
|
||
*/
|
||
final class WordPressIntegration implements Integration
|
||
{
|
||
public function __construct(private ContentSchema $schema, private ChangeLog $log)
|
||
{
|
||
}
|
||
|
||
public function id(): string
|
||
{
|
||
return 'wordpress';
|
||
}
|
||
|
||
public function isAvailable(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function abilities(): array
|
||
{
|
||
return [
|
||
new ContentTypesAbility($this->schema),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Guides/InstructionsBuilder.php` (komplett ersetzen):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Guides;
|
||
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
|
||
/**
|
||
* Builds the short server instructions every MCP client loads on connect.
|
||
* Keep this text short: it occupies context in every conversation.
|
||
*/
|
||
final class InstructionsBuilder
|
||
{
|
||
/** @var (callable(): list<string>)|null */
|
||
private $postTypeNames;
|
||
|
||
public function __construct(private IntegrationRegistry $registry, ?callable $postTypeNames = null)
|
||
{
|
||
$this->postTypeNames = $postTypeNames;
|
||
}
|
||
|
||
public function build(): string
|
||
{
|
||
$active = implode(', ', array_map(
|
||
static fn (Integration $integration): string => $integration->id(),
|
||
$this->registry->available()
|
||
));
|
||
|
||
$lines = [
|
||
'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).',
|
||
'Aktive Integrationen: ' . $active . '.',
|
||
];
|
||
|
||
if ($this->postTypeNames !== null) {
|
||
$lines[] = 'Inhaltstypen: ' . implode(', ', ($this->postTypeNames)())
|
||
. ' (Felder und Taxonomien: lit-content-types).';
|
||
}
|
||
|
||
return implode("\n", array_merge($lines, [
|
||
'Regeln:',
|
||
'- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide '
|
||
. 'mit topic.',
|
||
'- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, '
|
||
. 'wenn kein natives Modul passt.',
|
||
'- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich; '
|
||
. 'neue Inhalte entstehen als Entwurf.',
|
||
'- Beim Bearbeiten den rev-Wert aus dem letzten Lesevorgang mitgeben (bei Divi-Tools Pflicht).',
|
||
'- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).',
|
||
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
|
||
]));
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Plugin.php` (komplett ersetzen):
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP;
|
||
|
||
use LIT\MCP\Abilities\AbilityRegistrar;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||
use LIT\MCP\Guides\InstructionsBuilder;
|
||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
||
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
||
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
|
||
use LIT\MCP\Server\RequestContext;
|
||
use LIT\MCP\Server\ServerRegistrar;
|
||
|
||
/**
|
||
* Wires WP-LIT-MCP into WordPress.
|
||
*/
|
||
final class Plugin
|
||
{
|
||
public const VERSION = '0.1.0';
|
||
|
||
private static string $pluginFile = '';
|
||
|
||
public static function boot(string $pluginFile): void
|
||
{
|
||
self::$pluginFile = $pluginFile;
|
||
|
||
$missing = (new Dependencies())->missing();
|
||
if ($missing !== []) {
|
||
self::noticeMissing($missing);
|
||
return;
|
||
}
|
||
|
||
(new Access())->register();
|
||
(new RequestContext())->register();
|
||
|
||
$changeLog = new ChangeLog();
|
||
(new ChangeLogInstaller($changeLog))->maybeInstall();
|
||
(new ChangeLogCleanup($changeLog, new Limits()))->register();
|
||
|
||
$schema = self::contentSchema();
|
||
$registry = new IntegrationRegistry(
|
||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||
new WordPressIntegration($schema, $changeLog)
|
||
);
|
||
|
||
(new AbilityRegistrar($registry))->register();
|
||
(new ServerRegistrar(
|
||
$registry,
|
||
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames())
|
||
))->register();
|
||
|
||
\WPMedia\MCP\OAuth\Bootstrap::instance();
|
||
}
|
||
|
||
public static function pluginDir(): string
|
||
{
|
||
return dirname(self::$pluginFile);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $missing
|
||
*/
|
||
public static function noticeMissing(array $missing): void
|
||
{
|
||
add_action('admin_notices', static function () use ($missing): void {
|
||
printf(
|
||
'<div class="notice notice-error"><p>%s</p></div>',
|
||
esc_html(sprintf(
|
||
/* translators: %s: comma separated list of missing components */
|
||
__('WP-LIT-MCP ist inaktiv. Es fehlt: %s', 'wp-lit-mcp'),
|
||
implode(', ', $missing)
|
||
))
|
||
);
|
||
});
|
||
}
|
||
|
||
private static function contentSchema(): ContentSchema
|
||
{
|
||
$sources = [new RegisteredMetaSource(), new SampledMetaSource()];
|
||
if (PodsFieldSource::isAvailable()) {
|
||
array_unshift($sources, new PodsFieldSource());
|
||
}
|
||
|
||
return new ContentSchema($sources);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Integration – Tool sichtbar, Regression Etappe 0**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
|
||
timeout 120 ddev wp eval 'do_action("rest_api_init"); var_dump(wp_get_ability("lit/content-types") !== null);'
|
||
```
|
||
Expected: `ALL CHECKS PASSED` (Instructions enthalten weiterhin „LIT MCP pflegt“) und `bool(true)`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Plugin.php src/Guides/InstructionsBuilder.php src/Integrations/WordPress/WordPressIntegration.php src/Integrations/WordPress/Content/ContentTypesAbility.php tests/Unit/Integrations/WordPress tests/Unit/Guides/InstructionsBuilderTest.php
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: content-types tool and WordPress integration
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Validierung und Schreibhilfen (`FieldValidator`, `TermAssigner`, `PostGuard`, `RevisionPoint`)
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Schema/FieldValidator.php`, `src/Integrations/WordPress/Schema/TermAssigner.php`, `src/Integrations/WordPress/PostGuard.php`, `src/Integrations/WordPress/RevisionPoint.php`, `tests/Stubs/WpPost.php`, `tests/Stubs/WpTerm.php`
|
||
- Modify: `tests/bootstrap.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php`, `tests/Unit/Integrations/WordPress/Schema/TermAssignerTest.php`, `tests/Unit/Integrations/WordPress/PostGuardTest.php`, `tests/Unit/Integrations/WordPress/RevisionPointTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `FieldDefinition`, `ContentSchema` (Task 4), `Errors` (Task 1).
|
||
- Produces:
|
||
- `FieldValidator` (final): `validate(array<string, FieldDefinition> $definitions, array<string, mixed> $values, bool $isCreate): array{values: array<string, mixed>, errors: array<string, string>, warnings: list<string>}` – unbekannte und `readonly`-Felder sind Fehler; `unknown`-Felder erzeugen eine Warnung; `null` leert ein Feld; Pflichtfelder müssen beim Anlegen gesetzt sein und dürfen beim Ändern nicht geleert werden.
|
||
- `TermAssigner` (final): `resolve(string $postType, array $terms): array{terms: array<string, list<int|string>>, errors: array<string, string>}` (Eingabe `{taxonomie: [ID | Name, …]}`; Ints = vorhandene IDs, Strings = vorhandener Name/Slug → ID, sonst neuer Name, falls `edit_terms` erlaubt); `assign(int $postId, array $resolved): void` (legt neue Begriffe an und ersetzt die Zuordnung je Taxonomie; wirft `RuntimeException`).
|
||
- `PostGuard` (final, statisch): `editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error`; `postType(mixed $postType, ContentSchema $schema, string $cap): object|WP_Error` (`$cap` ist ein Schlüssel aus `$postTypeObject->cap`, z. B. `edit_posts`, `create_posts`); `id(mixed $value): ?int`.
|
||
- `RevisionPoint` (final, statisch): `capture(int $postId): ?int` (speichert den aktuellen Stand als Revision, falls geändert, und gibt die neueste Revisions-ID zurück); `latest(int $postId): ?int`.
|
||
- Test-Stubs `WP_Post` (Konstruktor `array $fields`) und `WP_Term` (Konstruktor `array $fields`).
|
||
|
||
- [ ] **Step 1: Stubs**
|
||
|
||
`tests/Stubs/WpPost.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! class_exists('WP_Post')) {
|
||
/**
|
||
* Minimal WP_Post: untyped public properties like the real class.
|
||
*/
|
||
class WP_Post
|
||
{
|
||
public $ID = 0;
|
||
public $post_type = 'post';
|
||
public $post_status = 'draft';
|
||
public $post_title = '';
|
||
public $post_content = '';
|
||
public $post_excerpt = '';
|
||
public $post_name = '';
|
||
public $post_parent = 0;
|
||
public $menu_order = 0;
|
||
public $post_author = '1';
|
||
public $post_date = '2026-09-25 10:00:00';
|
||
public $post_date_gmt = '2026-09-25 08:00:00';
|
||
public $post_modified = '2026-09-25 10:00:00';
|
||
public $post_mime_type = '';
|
||
public $comment_status = 'closed';
|
||
public $ping_status = 'closed';
|
||
public $post_password = '';
|
||
|
||
/** @param array<string, mixed> $fields */
|
||
public function __construct(array $fields = [])
|
||
{
|
||
foreach ($fields as $key => $value) {
|
||
$this->{$key} = $value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Stubs/WpTerm.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! class_exists('WP_Term')) {
|
||
class WP_Term
|
||
{
|
||
public $term_id = 0;
|
||
public $name = '';
|
||
public $slug = '';
|
||
public $taxonomy = '';
|
||
|
||
/** @param array<string, mixed> $fields */
|
||
public function __construct(array $fields = [])
|
||
{
|
||
foreach ($fields as $key => $value) {
|
||
$this->{$key} = $value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/bootstrap.php`: nach der Zeile mit `Stubs/Wpdb.php` ergänzen:
|
||
```php
|
||
require_once __DIR__ . '/Stubs/WpPost.php';
|
||
require_once __DIR__ . '/Stubs/WpTerm.php';
|
||
```
|
||
|
||
- [ ] **Step 2: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class FieldValidatorTest extends TestCase
|
||
{
|
||
/** @return array<string, FieldDefinition> */
|
||
private function definitions(): array
|
||
{
|
||
return [
|
||
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
|
||
'level' => new FieldDefinition('level', 'Stufe', 'choice', 'pods', 'writable', false, false, [
|
||
['value' => 'junior', 'label' => 'Junior'],
|
||
['value' => 'senior', 'label' => 'Senior'],
|
||
]),
|
||
'mentor' => new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable', false, false, null, [
|
||
'object_type' => 'post_type',
|
||
'name' => 'lit_person',
|
||
]),
|
||
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true, null, [
|
||
'object_type' => 'media',
|
||
'name' => null,
|
||
]),
|
||
'start' => new FieldDefinition('start', 'Beginn', 'date', 'pods', 'writable'),
|
||
'secret' => new FieldDefinition('_secret', '_secret', 'string', 'meta', 'readonly'),
|
||
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
|
||
];
|
||
}
|
||
|
||
public function testValidValuesPassUnchanged(): void
|
||
{
|
||
Functions\when('get_post_type')->alias(static fn (int $id) => [12 => 'lit_person', 3 => 'attachment'][$id] ?? false);
|
||
$values = ['role' => 'Leitung', 'level' => 'senior', 'mentor' => 12, 'photos' => [3], 'start' => '2026-10-01'];
|
||
|
||
$result = (new FieldValidator())->validate($this->definitions(), $values, true);
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame($values, $result['values']);
|
||
}
|
||
|
||
public function testRequiredFieldMustBeSetOnCreateAndNotClearedOnUpdate(): void
|
||
{
|
||
$validator = new FieldValidator();
|
||
|
||
self::assertSame('Pflichtfeld fehlt.', $validator->validate($this->definitions(), [], true)['errors']['role']);
|
||
self::assertSame(
|
||
'Pflichtfeld darf nicht geleert werden.',
|
||
$validator->validate($this->definitions(), ['role' => ''], false)['errors']['role']
|
||
);
|
||
self::assertSame([], $validator->validate($this->definitions(), ['level' => 'junior'], false)['errors']);
|
||
}
|
||
|
||
public function testUnknownReadonlyAndInvalidValuesAreReported(): void
|
||
{
|
||
Functions\when('get_post_type')->alias(static fn (int $id) => [7 => 'page', 3 => 'attachment'][$id] ?? false);
|
||
|
||
$errors = (new FieldValidator())->validate($this->definitions(), [
|
||
'role' => 'x',
|
||
'nope' => 1,
|
||
'secret' => 'x',
|
||
'level' => 'expert',
|
||
'mentor' => 7,
|
||
'photos' => 3,
|
||
'start' => '01.10.2026',
|
||
], false)['errors'];
|
||
|
||
self::assertStringContainsString('lit-content-types', $errors['nope']);
|
||
self::assertSame('Feld ist schreibgeschützt.', $errors['secret']);
|
||
self::assertSame('Erlaubte Werte: junior, senior.', $errors['level']);
|
||
self::assertSame('Eintrag 7 ist vom Typ page, erwartet lit_person.', $errors['mentor']);
|
||
self::assertSame('Liste erwartet.', $errors['photos']);
|
||
self::assertSame('Datum im Format JJJJ-MM-TT erwartet.', $errors['start']);
|
||
self::assertArrayNotHasKey('role', $errors);
|
||
}
|
||
|
||
public function testMissingRelatedPostAndNonMediaAreRejected(): void
|
||
{
|
||
Functions\when('get_post_type')->alias(static fn (int $id) => [5 => 'page'][$id] ?? false);
|
||
|
||
$errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 99, 'photos' => [5]], false)['errors'];
|
||
|
||
self::assertSame('Eintrag 99 existiert nicht.', $errors['mentor']);
|
||
self::assertSame('5 ist kein Medium der Mediathek.', $errors['photos']);
|
||
}
|
||
|
||
public function testUnknownTypedFieldsAreAcceptedWithAWarningAndNullClears(): void
|
||
{
|
||
$result = (new FieldValidator())->validate($this->definitions(), ['city' => ['any' => 'thing'], 'level' => null], false);
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame(['city' => ['any' => 'thing'], 'level' => null], $result['values']);
|
||
self::assertCount(1, $result['warnings']);
|
||
self::assertStringContainsString('city', $result['warnings'][0]);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Schema/TermAssignerTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
use WP_Term;
|
||
|
||
final class TermAssignerTest extends TestCase
|
||
{
|
||
private function stubTaxonomies(bool $mayEditTerms = true, bool $mayAssign = true): void
|
||
{
|
||
Functions\when('get_object_taxonomies')->justReturn(['category', 'post_tag']);
|
||
Functions\when('get_taxonomy')->justReturn((object) [
|
||
'cap' => (object) ['assign_terms' => 'assign_categories', 'edit_terms' => 'manage_categories'],
|
||
]);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => match ($cap) {
|
||
'assign_categories' => $mayAssign,
|
||
'manage_categories' => $mayEditTerms,
|
||
default => false,
|
||
});
|
||
Functions\when('get_term')->alias(static fn (int $id) => $id === 4 ? new WP_Term(['term_id' => 4]) : null);
|
||
Functions\when('term_exists')->alias(
|
||
static fn (string $name) => $name === 'News' ? ['term_id' => '9', 'term_taxonomy_id' => '9'] : null
|
||
);
|
||
}
|
||
|
||
public function testResolvesIdsExistingNamesAndNewNames(): void
|
||
{
|
||
$this->stubTaxonomies();
|
||
|
||
$result = (new TermAssigner())->resolve('post', ['category' => [4, 'News', 'Neu']]);
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame(['category' => [4, 9, 'Neu']], $result['terms']);
|
||
}
|
||
|
||
public function testReportsForeignTaxonomyMissingIdsAndMissingRights(): void
|
||
{
|
||
$this->stubTaxonomies(false);
|
||
|
||
$result = (new TermAssigner())->resolve('post', [
|
||
'genre' => [1],
|
||
'category' => [77],
|
||
'post_tag' => ['Unbekannt'],
|
||
]);
|
||
|
||
self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $result['errors']['genre']);
|
||
self::assertSame('Begriff 77 existiert nicht.', $result['errors']['category']);
|
||
self::assertStringContainsString('keine Berechtigung', $result['errors']['post_tag']);
|
||
}
|
||
|
||
public function testAssignRightsAreRequired(): void
|
||
{
|
||
$this->stubTaxonomies(true, false);
|
||
|
||
$result = (new TermAssigner())->resolve('post', ['category' => [4]]);
|
||
|
||
self::assertSame('Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.', $result['errors']['category']);
|
||
}
|
||
|
||
public function testAssignCreatesNewTermsAndReplacesTheAssignment(): void
|
||
{
|
||
Functions\expect('wp_insert_term')->once()->with('Neu', 'category')->andReturn(['term_id' => 15]);
|
||
Functions\expect('wp_set_object_terms')->once()->with(5, [4, 15], 'category', false)->andReturn([4, 15]);
|
||
|
||
(new TermAssigner())->assign(5, ['category' => [4, 'Neu']]);
|
||
}
|
||
|
||
public function testAssignFailureThrows(): void
|
||
{
|
||
Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt'));
|
||
|
||
$this->expectException(RuntimeException::class);
|
||
|
||
(new TermAssigner())->assign(5, ['category' => [4]]);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/PostGuardTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class PostGuardTest extends TestCase
|
||
{
|
||
private function schema(): ContentSchema
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturnUsing(static fn (string $type): bool => in_array($type, ['page', 'post'], true));
|
||
|
||
return $schema;
|
||
}
|
||
|
||
public function testRejectsInvalidIds(): void
|
||
{
|
||
self::assertSame('lit_validation_failed', PostGuard::editable('abc', $this->schema())->get_error_code());
|
||
self::assertSame('lit_validation_failed', PostGuard::editable(-3, $this->schema())->get_error_code());
|
||
}
|
||
|
||
public function testMissingOrUnmanagedPostsAreNotFound(): void
|
||
{
|
||
Functions\when('get_post')->alias(static fn (int $id) => $id === 8 ? new WP_Post(['ID' => 8, 'post_type' => 'et_pb_layout']) : null);
|
||
|
||
self::assertSame('lit_not_found', PostGuard::editable(5, $this->schema())->get_error_code());
|
||
self::assertSame('lit_not_found', PostGuard::editable(8, $this->schema())->get_error_code());
|
||
}
|
||
|
||
public function testRequiresEditRightsAndReturnsThePost(): void
|
||
{
|
||
$post = new WP_Post(['ID' => 5, 'post_type' => 'page']);
|
||
Functions\when('get_post')->justReturn($post);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id === 5);
|
||
|
||
self::assertSame($post, PostGuard::editable('5', $this->schema()));
|
||
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code());
|
||
}
|
||
|
||
public function testPostTypeChecksAvailabilityAndCapability(): void
|
||
{
|
||
$object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']];
|
||
Functions\when('get_post_type_object')->justReturn($object);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_pages');
|
||
|
||
self::assertSame('lit_not_found', PostGuard::postType('et_pb_layout', $this->schema(), 'create_posts')->get_error_code());
|
||
self::assertSame('lit_not_found', PostGuard::postType(null, $this->schema(), 'create_posts')->get_error_code());
|
||
self::assertSame($object, PostGuard::postType('page', $this->schema(), 'create_posts'));
|
||
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
self::assertSame('lit_forbidden', PostGuard::postType('page', $this->schema(), 'create_posts')->get_error_code());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/RevisionPointTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\RevisionPoint;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
|
||
final class RevisionPointTest extends TestCase
|
||
{
|
||
public function testCaptureSavesARevisionAndReturnsTheNewest(): void
|
||
{
|
||
$order = [];
|
||
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
|
||
$order[] = 'save';
|
||
return 811;
|
||
});
|
||
Functions\expect('wp_get_post_revisions')->once()->with(5, ['posts_per_page' => 1])
|
||
->andReturnUsing(static function () use (&$order): array {
|
||
$order[] = 'latest';
|
||
return [811 => new WP_Post(['ID' => 811, 'post_type' => 'revision'])];
|
||
});
|
||
|
||
self::assertSame(811, RevisionPoint::capture(5));
|
||
self::assertSame(['save', 'latest'], $order);
|
||
}
|
||
|
||
public function testLatestIsNullWithoutRevisions(): void
|
||
{
|
||
Functions\when('wp_get_post_revisions')->justReturn([]);
|
||
|
||
self::assertNull(RevisionPoint::latest(5));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'FieldValidatorTest|TermAssignerTest|PostGuardTest|RevisionPointTest'`
|
||
Expected: FAIL, Klassen nicht gefunden.
|
||
|
||
- [ ] **Step 4: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Schema/FieldValidator.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
use DateTimeImmutable;
|
||
|
||
/**
|
||
* Checks field values against the detected schema before anything is written.
|
||
*/
|
||
final class FieldValidator
|
||
{
|
||
private const LIST_TYPES = ['relation', 'file', 'choice', 'array'];
|
||
|
||
/**
|
||
* @param array<string, FieldDefinition> $definitions
|
||
* @param array<string, mixed> $values
|
||
* @return array{values: array<string, mixed>, errors: array<string, string>, warnings: list<string>}
|
||
*/
|
||
public function validate(array $definitions, array $values, bool $isCreate): array
|
||
{
|
||
$clean = [];
|
||
$errors = [];
|
||
$warnings = [];
|
||
|
||
foreach ($values as $key => $value) {
|
||
$key = (string) $key;
|
||
$field = $definitions[$key] ?? null;
|
||
if ($field === null) {
|
||
$errors[$key] = 'Unbekanntes Feld; verfügbare Felder über lit-content-types abrufen.';
|
||
continue;
|
||
}
|
||
|
||
if ($field->status === FieldDefinition::READONLY) {
|
||
$errors[$key] = 'Feld ist schreibgeschützt.';
|
||
continue;
|
||
}
|
||
|
||
$error = $value === null ? null : $this->check($field, $value);
|
||
if ($error !== null) {
|
||
$errors[$key] = $error;
|
||
continue;
|
||
}
|
||
|
||
if ($field->status === FieldDefinition::UNKNOWN) {
|
||
$warnings[] = sprintf(
|
||
'Feld "%s" hat keinen bekannten Typ; der Wert wurde unverändert gespeichert.',
|
||
$key
|
||
);
|
||
}
|
||
|
||
$clean[$key] = $value;
|
||
}
|
||
|
||
foreach ($definitions as $key => $field) {
|
||
if (! $field->required || isset($errors[$key])) {
|
||
continue;
|
||
}
|
||
|
||
$provided = array_key_exists($key, $clean);
|
||
if ($isCreate && (! $provided || $this->isEmpty($clean[$key]))) {
|
||
$errors[$key] = 'Pflichtfeld fehlt.';
|
||
} elseif (! $isCreate && $provided && $this->isEmpty($clean[$key])) {
|
||
$errors[$key] = 'Pflichtfeld darf nicht geleert werden.';
|
||
}
|
||
}
|
||
|
||
return ['values' => $clean, 'errors' => $errors, 'warnings' => $warnings];
|
||
}
|
||
|
||
private function check(FieldDefinition $field, mixed $value): ?string
|
||
{
|
||
if ($field->multiple && in_array($field->type, self::LIST_TYPES, true)) {
|
||
if (! is_array($value) || ! array_is_list($value)) {
|
||
return 'Liste erwartet.';
|
||
}
|
||
|
||
if ($field->type === 'array') {
|
||
return null;
|
||
}
|
||
|
||
foreach ($value as $item) {
|
||
$error = $this->checkScalar($field, $item);
|
||
if ($error !== null) {
|
||
return $error;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
return $this->checkScalar($field, $value);
|
||
}
|
||
|
||
private function checkScalar(FieldDefinition $field, mixed $value): ?string
|
||
{
|
||
return match ($field->type) {
|
||
'string', 'text' => is_string($value) ? null : 'Text erwartet.',
|
||
'integer' => is_int($value) ? null : 'Ganzzahl erwartet.',
|
||
'number' => is_int($value) || is_float($value) ? null : 'Zahl erwartet.',
|
||
'boolean' => is_bool($value) ? null : 'true oder false erwartet.',
|
||
'date' => $this->matchesFormat($value, 'Y-m-d') ? null : 'Datum im Format JJJJ-MM-TT erwartet.',
|
||
'datetime' => $this->matchesFormat($value, 'Y-m-d H:i:s')
|
||
? null
|
||
: 'Datum und Uhrzeit im Format JJJJ-MM-TT HH:MM:SS erwartet.',
|
||
'time' => $this->matchesFormat($value, 'H:i:s') ? null : 'Uhrzeit im Format HH:MM:SS erwartet.',
|
||
'choice' => $this->checkChoice($field, $value),
|
||
'relation', 'file' => $this->checkRelation($field, $value),
|
||
'array' => is_array($value) && array_is_list($value) ? null : 'Liste erwartet.',
|
||
'object' => is_array($value) ? null : 'Objekt erwartet.',
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
private function checkChoice(FieldDefinition $field, mixed $value): ?string
|
||
{
|
||
$allowed = array_column($field->choices ?? [], 'value');
|
||
|
||
return is_string($value) && in_array($value, $allowed, true)
|
||
? null
|
||
: sprintf('Erlaubte Werte: %s.', implode(', ', $allowed));
|
||
}
|
||
|
||
private function checkRelation(FieldDefinition $field, mixed $value): ?string
|
||
{
|
||
if (! is_int($value) || $value <= 0) {
|
||
return 'ID (positive Ganzzahl) erwartet.';
|
||
}
|
||
|
||
$objectType = $field->related['object_type'] ?? '';
|
||
if ($objectType === 'media') {
|
||
return get_post_type($value) === 'attachment'
|
||
? null
|
||
: sprintf('%d ist kein Medium der Mediathek.', $value);
|
||
}
|
||
|
||
if ($objectType === 'post_type') {
|
||
$actual = get_post_type($value);
|
||
if ($actual === false) {
|
||
return sprintf('Eintrag %d existiert nicht.', $value);
|
||
}
|
||
|
||
$expected = $field->related['name'] ?? null;
|
||
if ($expected !== null && $actual !== $expected) {
|
||
return sprintf('Eintrag %d ist vom Typ %s, erwartet %s.', $value, $actual, $expected);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function matchesFormat(mixed $value, string $format): bool
|
||
{
|
||
if (! is_string($value)) {
|
||
return false;
|
||
}
|
||
|
||
$parsed = DateTimeImmutable::createFromFormat('!' . $format, $value);
|
||
|
||
return $parsed !== false && $parsed->format($format) === $value;
|
||
}
|
||
|
||
private function isEmpty(mixed $value): bool
|
||
{
|
||
return $value === null || $value === '' || $value === [];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Schema/TermAssigner.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
use RuntimeException;
|
||
use WP_Term;
|
||
|
||
/**
|
||
* Resolves term input ({taxonomy: [id | name, …]}) and replaces a post's terms.
|
||
* resolve() never writes, so a whole call can be validated first (all or nothing).
|
||
*/
|
||
final class TermAssigner
|
||
{
|
||
/**
|
||
* @param array<string, mixed> $terms
|
||
* @return array{terms: array<string, list<int|string>>, errors: array<string, string>}
|
||
*/
|
||
public function resolve(string $postType, array $terms): array
|
||
{
|
||
$allowed = get_object_taxonomies($postType, 'names');
|
||
$resolved = [];
|
||
$errors = [];
|
||
|
||
foreach ($terms as $taxonomy => $items) {
|
||
$taxonomy = (string) $taxonomy;
|
||
if (! in_array($taxonomy, $allowed, true)) {
|
||
$errors[$taxonomy] = 'Taxonomie gehört nicht zu diesem Inhaltstyp.';
|
||
continue;
|
||
}
|
||
|
||
$object = get_taxonomy($taxonomy);
|
||
if (! is_object($object) || ! current_user_can($object->cap->assign_terms)) {
|
||
$errors[$taxonomy] = 'Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.';
|
||
continue;
|
||
}
|
||
|
||
$result = $this->resolveItems($taxonomy, is_array($items) ? $items : [$items], $object);
|
||
if (is_string($result)) {
|
||
$errors[$taxonomy] = $result;
|
||
continue;
|
||
}
|
||
|
||
$resolved[$taxonomy] = $result;
|
||
}
|
||
|
||
return ['terms' => $resolved, 'errors' => $errors];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, list<int|string>> $resolved Output of resolve().
|
||
* @throws RuntimeException
|
||
*/
|
||
public function assign(int $postId, array $resolved): void
|
||
{
|
||
foreach ($resolved as $taxonomy => $items) {
|
||
$ids = [];
|
||
foreach ($items as $item) {
|
||
if (is_int($item)) {
|
||
$ids[] = $item;
|
||
continue;
|
||
}
|
||
|
||
$created = wp_insert_term($item, $taxonomy);
|
||
if (is_wp_error($created)) {
|
||
throw new RuntimeException('Term could not be created: ' . $created->get_error_message());
|
||
}
|
||
$ids[] = (int) $created['term_id'];
|
||
}
|
||
|
||
$result = wp_set_object_terms($postId, $ids, $taxonomy, false);
|
||
if (is_wp_error($result)) {
|
||
throw new RuntimeException('Terms could not be assigned: ' . $result->get_error_message());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param list<mixed> $items
|
||
* @return list<int|string>|string Resolved items or an error message.
|
||
*/
|
||
private function resolveItems(string $taxonomy, array $items, object $object): array|string
|
||
{
|
||
$resolved = [];
|
||
foreach ($items as $item) {
|
||
if (is_int($item)) {
|
||
if (! get_term($item, $taxonomy) instanceof WP_Term) {
|
||
return sprintf('Begriff %d existiert nicht.', $item);
|
||
}
|
||
$resolved[] = $item;
|
||
continue;
|
||
}
|
||
|
||
if (! is_string($item) || trim($item) === '') {
|
||
return 'Begriffe als ID (Zahl) oder Name (Text) angeben.';
|
||
}
|
||
|
||
$existing = term_exists(trim($item), $taxonomy);
|
||
if (is_array($existing)) {
|
||
$resolved[] = (int) $existing['term_id'];
|
||
continue;
|
||
}
|
||
|
||
if (! current_user_can($object->cap->edit_terms)) {
|
||
return sprintf('Begriff "%s" existiert nicht; keine Berechtigung, ihn anzulegen.', trim($item));
|
||
}
|
||
$resolved[] = trim($item);
|
||
}
|
||
|
||
return $resolved;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/PostGuard.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* Loads entries and post types for abilities, including the capability checks
|
||
* of spec §4.5. Every error is ready to be returned from permission()/execute().
|
||
* Input is not schema-validated yet when permission() runs – hence the defensive id().
|
||
*/
|
||
final class PostGuard
|
||
{
|
||
public static function editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error
|
||
{
|
||
$id = self::id($postId);
|
||
if ($id === null) {
|
||
return Errors::invalidInput(
|
||
sprintf('%s fehlt oder ist ungültig.', $field),
|
||
[$field => 'positive Ganzzahl erwartet']
|
||
);
|
||
}
|
||
|
||
$post = get_post($id);
|
||
if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) {
|
||
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id));
|
||
}
|
||
|
||
if (! current_user_can('edit_post', $id)) {
|
||
return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id));
|
||
}
|
||
|
||
return $post;
|
||
}
|
||
|
||
public static function postType(mixed $postType, ContentSchema $schema, string $cap): object|WP_Error
|
||
{
|
||
$name = is_string($postType) ? $postType : '';
|
||
$object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null;
|
||
if (! is_object($object)) {
|
||
return Errors::notFound(
|
||
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
|
||
'Verfügbare Inhaltstypen über lit-content-types abrufen.'
|
||
);
|
||
}
|
||
|
||
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;
|
||
if (! current_user_can($capability)) {
|
||
return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $name));
|
||
}
|
||
|
||
return $object;
|
||
}
|
||
|
||
public static function id(mixed $value): ?int
|
||
{
|
||
if (is_int($value)) {
|
||
return $value > 0 ? $value : null;
|
||
}
|
||
|
||
if (is_string($value) && ctype_digit($value) && (int) $value > 0) {
|
||
return (int) $value;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/RevisionPoint.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress;
|
||
|
||
/**
|
||
* Restore point before a change (spec §2 principle 7): WordPress stores the
|
||
* current state as a revision (only if it differs from the newest one) and the
|
||
* newest revision ID is returned as revision_id of the ToolResult.
|
||
* Note: revisions cover title, content and excerpt; meta and terms are not revisioned.
|
||
*/
|
||
final class RevisionPoint
|
||
{
|
||
public static function capture(int $postId): ?int
|
||
{
|
||
wp_save_post_revision($postId);
|
||
|
||
return self::latest($postId);
|
||
}
|
||
|
||
public static function latest(int $postId): ?int
|
||
{
|
||
$revisions = wp_get_post_revisions($postId, ['posts_per_page' => 1]);
|
||
$newest = is_array($revisions) ? reset($revisions) : false;
|
||
|
||
return is_object($newest) ? (int) $newest->ID : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress/Schema/FieldValidator.php src/Integrations/WordPress/Schema/TermAssigner.php src/Integrations/WordPress/PostGuard.php src/Integrations/WordPress/RevisionPoint.php tests/Stubs tests/bootstrap.php tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: field validation, term resolution, post guard and revision points
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 8: Lesen – `PostPresenter`, `lit/content-query`, `lit/content-get`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Content/PostPresenter.php`, `src/Integrations/WordPress/Content/ContentQueryAbility.php`, `src/Integrations/WordPress/Content/ContentGetAbility.php`
|
||
- Modify: `src/Integrations/WordPress/WordPressIntegration.php` (`abilities()` + Imports), `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` (`EXPECTED`)
|
||
- Test: `tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php`, `tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php`, `tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ContentSchema::fields()/sourceOf()/taxonomies()/isManaged()` (Task 4), `PostGuard` (Task 7), `Cursor`, `Rev`, `JsonMap` (Task 1).
|
||
- Produces:
|
||
- `PostPresenter` (nicht final): `__construct(ContentSchema $schema)`; `summary(WP_Post $post): array{id, type, status, title, slug, parent, date, modified, url, edit_url, divi}`; `full(WP_Post $post): array` (Zusammenfassung + `excerpt`, `content` (`null` bei Divi), `menu_order`, `author{id,name}`, `template`, `preview_url`, `terms` (Map Taxonomie → `list<{id,name,slug}>`), `fields` (Map Schlüssel → Wert), `featured_image` (`{id,url,alt}`|null), `rev`, `hints`); `static usesDivi(WP_Post $post): bool`; `static summarySchema(): array`, `static fullSchema(): array` (Output-Schemas).
|
||
- `ContentQueryAbility`: `__construct(ContentSchema $schema, PostPresenter $presenter)`; Name `lit/content-query`; Output `{items: list<summary>, next_cursor: ?string}`; direkt; readonly, idempotent.
|
||
- `ContentGetAbility`: `__construct(ContentSchema $schema, PostPresenter $presenter)`; Name `lit/content-get`; Input `{post_id: int}`; Output `full`; direkt; readonly, idempotent.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use stdClass;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
final class PostPresenterTest extends TestCase
|
||
{
|
||
private function stubLinks(): void
|
||
{
|
||
Functions\when('get_permalink')->justReturn('https://kunde.de/team/');
|
||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
|
||
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=5&preview=true');
|
||
Functions\when('get_the_author_meta')->justReturn('Jan');
|
||
Functions\when('get_page_template_slug')->justReturn('');
|
||
}
|
||
|
||
public function testSummaryContainsCoreDataAndUrls(): void
|
||
{
|
||
$this->stubLinks();
|
||
Functions\when('get_post_meta')->justReturn('');
|
||
$post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_name' => 'team']);
|
||
|
||
$summary = (new PostPresenter(Mockery::mock(ContentSchema::class)))->summary($post);
|
||
|
||
self::assertSame(5, $summary['id']);
|
||
self::assertSame('Team', $summary['title']);
|
||
self::assertSame('team', $summary['slug']);
|
||
self::assertSame('https://kunde.de/team/', $summary['url']);
|
||
self::assertFalse($summary['divi']);
|
||
}
|
||
|
||
public function testFullReadsTermsFieldsAndFeaturedImage(): void
|
||
{
|
||
$this->stubLinks();
|
||
Functions\when('get_post_meta')->alias(static fn (int $id, string $key) => match ($key) {
|
||
'_wp_attachment_image_alt' => 'Porträt',
|
||
default => '',
|
||
});
|
||
Functions\when('wp_get_post_terms')->justReturn([new WP_Term(['term_id' => 4, 'name' => 'News', 'slug' => 'news'])]);
|
||
Functions\when('get_post_thumbnail_id')->justReturn(33);
|
||
Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/foto.jpg');
|
||
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
|
||
$source = Mockery::mock(FieldSource::class);
|
||
$source->expects('read')->with(5, $role)->andReturn('Leitung');
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('taxonomies')->andReturn([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]]);
|
||
$schema->allows('fields')->andReturn(['role' => $role]);
|
||
$schema->allows('sourceOf')->andReturn($source);
|
||
$post = new WP_Post(['ID' => 5, 'post_type' => 'lit_person', 'post_content' => '<p>Hallo</p>', 'post_author' => '2']);
|
||
|
||
$full = (new PostPresenter($schema))->full($post);
|
||
|
||
self::assertSame('<p>Hallo</p>', $full['content']);
|
||
self::assertSame(['category' => [['id' => 4, 'name' => 'News', 'slug' => 'news']]], $full['terms']);
|
||
self::assertSame(['role' => 'Leitung'], $full['fields']);
|
||
self::assertSame(['id' => 33, 'url' => 'https://kunde.de/wp-content/uploads/foto.jpg', 'alt' => 'Porträt'], $full['featured_image']);
|
||
self::assertSame(['id' => 2, 'name' => 'Jan'], $full['author']);
|
||
self::assertSame(Rev::of('<p>Hallo</p>'), $full['rev']);
|
||
self::assertSame([], $full['hints']);
|
||
}
|
||
|
||
public function testDiviContentIsWithheldAndPointsToTheDiviOutline(): void
|
||
{
|
||
$this->stubLinks();
|
||
Functions\when('get_post_meta')->alias(static fn (int $id, string $key) => $key === '_et_pb_use_builder' ? 'on' : '');
|
||
Functions\when('get_post_thumbnail_id')->justReturn(0);
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('taxonomies')->andReturn([]);
|
||
$schema->allows('fields')->andReturn([]);
|
||
$post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_content' => '<!-- wp:divi/section /-->']);
|
||
|
||
$full = (new PostPresenter($schema))->full($post);
|
||
|
||
self::assertTrue($full['divi']);
|
||
self::assertNull($full['content']);
|
||
self::assertNull($full['featured_image']);
|
||
self::assertStringContainsString('lit-divi-outline', $full['hints'][0]);
|
||
self::assertInstanceOf(stdClass::class, $full['terms']);
|
||
self::assertInstanceOf(stdClass::class, $full['fields']);
|
||
}
|
||
|
||
public function testDiviIsAlsoDetectedFromBlockMarkup(): void
|
||
{
|
||
Functions\when('get_post_meta')->justReturn('');
|
||
|
||
self::assertTrue(PostPresenter::usesDivi(new WP_Post(['post_content' => 'x <!-- wp:divi/text {} /--> y'])));
|
||
self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '<!-- wp:paragraph -->'])));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class ContentQueryAbilityTest extends TestCase
|
||
{
|
||
private function ability(): ContentQueryAbility
|
||
{
|
||
$presenter = Mockery::mock(PostPresenter::class);
|
||
$presenter->allows('summary')->andReturnUsing(static fn (WP_Post $post): array => ['id' => $post->ID]);
|
||
|
||
return new ContentQueryAbility(Mockery::mock(ContentSchema::class), $presenter);
|
||
}
|
||
|
||
public function testBuildsTheQueryAndPaginates(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\expect('get_posts')->once()->with(Mockery::on(static function (array $args): bool {
|
||
return $args['post_type'] === 'lit_person'
|
||
&& $args['post_status'] === ['draft']
|
||
&& $args['s'] === 'Anna'
|
||
&& $args['posts_per_page'] === 3
|
||
&& $args['offset'] === 4
|
||
&& $args['orderby'] === 'title'
|
||
&& $args['order'] === 'ASC'
|
||
&& $args['tax_query'] === [
|
||
'relation' => 'OR',
|
||
['taxonomy' => 'category', 'field' => 'term_id', 'terms' => [4]],
|
||
['taxonomy' => 'category', 'field' => 'slug', 'terms' => ['news']],
|
||
]
|
||
&& $args['meta_query'] === [['key' => 'level', 'compare' => '=', 'value' => 'senior']]
|
||
&& $args['date_query'] === [['after' => '2026-01-01', 'inclusive' => true]];
|
||
}))->andReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2]), new WP_Post(['ID' => 3])]);
|
||
|
||
$result = $this->ability()->execute([
|
||
'post_type' => 'lit_person',
|
||
'status' => ['draft'],
|
||
'search' => 'Anna',
|
||
'taxonomy' => ['taxonomy' => 'category', 'terms' => [4, 'news']],
|
||
'field' => ['key' => 'level', 'value' => 'senior'],
|
||
'date_after' => '2026-01-01',
|
||
'orderby' => 'title',
|
||
'order' => 'asc',
|
||
'limit' => 2,
|
||
'cursor' => Cursor::encode(4),
|
||
]);
|
||
|
||
self::assertSame([['id' => 1], ['id' => 2]], $result['items']);
|
||
self::assertSame(6, Cursor::decode($result['next_cursor']));
|
||
}
|
||
|
||
public function testExistsFilterHasNoValueAndLastPageHasNoCursor(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\expect('get_posts')->once()->with(Mockery::on(
|
||
static fn (array $args): bool => $args['meta_query'] === [['key' => 'role', 'compare' => 'EXISTS']]
|
||
&& $args['post_status'] === ['publish', 'draft', 'pending', 'private', 'future']
|
||
))->andReturn([new WP_Post(['ID' => 1])]);
|
||
|
||
$result = $this->ability()->execute(['post_type' => 'page', 'field' => ['key' => 'role', 'compare' => 'EXISTS']]);
|
||
|
||
self::assertNull($result['next_cursor']);
|
||
}
|
||
|
||
public function testPostsTheUserCannotEditAreDropped(): void
|
||
{
|
||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2);
|
||
Functions\when('get_posts')->justReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2])]);
|
||
|
||
$result = $this->ability()->execute(['post_type' => 'page']);
|
||
|
||
self::assertSame([['id' => 1]], $result['items']);
|
||
}
|
||
|
||
public function testInvalidCursorIsRejected(): void
|
||
{
|
||
$result = $this->ability()->execute(['post_type' => 'page', 'cursor' => '???']);
|
||
|
||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||
}
|
||
|
||
public function testPermissionNeedsAManagedTypeAndEditRights(): void
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(false);
|
||
$ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class));
|
||
|
||
self::assertSame('lit_not_found', $ability->permission(['post_type' => 'secret'])->get_error_code());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class ContentGetAbilityTest extends TestCase
|
||
{
|
||
public function testReturnsThePresentedPost(): void
|
||
{
|
||
$post = new WP_Post(['ID' => 5, 'post_type' => 'page']);
|
||
Functions\when('get_post')->justReturn($post);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
$presenter = Mockery::mock(PostPresenter::class);
|
||
$presenter->expects('full')->with($post)->andReturn(['id' => 5]);
|
||
$ability = new ContentGetAbility($schema, $presenter);
|
||
|
||
self::assertTrue($ability->permission(['post_id' => 5]));
|
||
self::assertSame(['id' => 5], $ability->execute(['post_id' => 5]));
|
||
}
|
||
|
||
public function testMissingPostIsNotFound(): void
|
||
{
|
||
Functions\when('get_post')->justReturn(null);
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$ability = new ContentGetAbility($schema, Mockery::mock(PostPresenter::class));
|
||
|
||
self::assertSame('lit_not_found', $ability->permission(['post_id' => 99])->get_error_code());
|
||
}
|
||
|
||
public function testOutputSchemaAllowsNullContentForDivi(): void
|
||
{
|
||
$ability = new ContentGetAbility(Mockery::mock(ContentSchema::class), Mockery::mock(PostPresenter::class));
|
||
|
||
self::assertSame(['string', 'null'], $ability->outputSchema()['properties']['content']['type']);
|
||
self::assertTrue($ability->isDirect());
|
||
}
|
||
}
|
||
```
|
||
|
||
In `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` die Konstante ersetzen:
|
||
```php
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
'lit/content-query',
|
||
'lit/content-get',
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'PostPresenterTest|ContentQueryAbilityTest|ContentGetAbilityTest|WordPressIntegrationTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Content/PostPresenter.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||
|
||
use LIT\MCP\Abilities\JsonMap;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* Output format of an entry for the content tools. Not final so abilities can be
|
||
* unit-tested with a mock.
|
||
*/
|
||
class PostPresenter
|
||
{
|
||
public const DIVI_HINT = 'Dieser Inhalt ist mit Divi gebaut. Die Divi-Struktur wird über lit-divi-outline '
|
||
. 'gelesen und mit den lit-divi-*-Tools bearbeitet; fehlen diese Tools, kann der Divi-Inhalt hier nicht '
|
||
. 'bearbeitet werden. content wird deshalb nicht ausgegeben; Titel, Auszug, Felder und Begriffe sind '
|
||
. 'über lit-content-update änderbar.';
|
||
|
||
public function __construct(private ContentSchema $schema)
|
||
{
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function summary(WP_Post $post): array
|
||
{
|
||
return [
|
||
'id' => (int) $post->ID,
|
||
'type' => (string) $post->post_type,
|
||
'status' => (string) $post->post_status,
|
||
'title' => (string) $post->post_title,
|
||
'slug' => (string) $post->post_name,
|
||
'parent' => (int) $post->post_parent,
|
||
'date' => (string) $post->post_date,
|
||
'modified' => (string) $post->post_modified,
|
||
'url' => (string) get_permalink($post),
|
||
'edit_url' => (string) get_edit_post_link($post->ID, 'raw'),
|
||
'divi' => self::usesDivi($post),
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function full(WP_Post $post): array
|
||
{
|
||
$divi = self::usesDivi($post);
|
||
|
||
return array_merge($this->summary($post), [
|
||
'excerpt' => (string) $post->post_excerpt,
|
||
'content' => $divi ? null : (string) $post->post_content,
|
||
'menu_order' => (int) $post->menu_order,
|
||
'author' => [
|
||
'id' => (int) $post->post_author,
|
||
'name' => (string) get_the_author_meta('display_name', (int) $post->post_author),
|
||
],
|
||
'template' => (string) get_page_template_slug($post),
|
||
'preview_url' => (string) get_preview_post_link($post),
|
||
'terms' => JsonMap::of($this->terms($post)),
|
||
'fields' => JsonMap::of($this->fields($post)),
|
||
'featured_image' => $this->featuredImage($post),
|
||
'rev' => Rev::of((string) $post->post_content),
|
||
'hints' => $divi ? [self::DIVI_HINT] : [],
|
||
]);
|
||
}
|
||
|
||
public static function usesDivi(WP_Post $post): bool
|
||
{
|
||
return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on'
|
||
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public static function summarySchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'integer'],
|
||
'type' => ['type' => 'string'],
|
||
'status' => ['type' => 'string'],
|
||
'title' => ['type' => 'string'],
|
||
'slug' => ['type' => 'string'],
|
||
'parent' => ['type' => 'integer'],
|
||
'date' => ['type' => 'string'],
|
||
'modified' => ['type' => 'string'],
|
||
'url' => ['type' => 'string'],
|
||
'edit_url' => ['type' => 'string'],
|
||
'divi' => ['type' => 'boolean'],
|
||
],
|
||
'required' => ['id', 'type', 'status', 'title', 'url', 'divi'],
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public static function fullSchema(): array
|
||
{
|
||
$schema = self::summarySchema();
|
||
$schema['properties'] = array_merge($schema['properties'], [
|
||
'excerpt' => ['type' => 'string'],
|
||
'content' => ['type' => ['string', 'null']],
|
||
'menu_order' => ['type' => 'integer'],
|
||
'author' => ['type' => 'object'],
|
||
'template' => ['type' => 'string'],
|
||
'preview_url' => ['type' => 'string'],
|
||
'terms' => ['type' => 'object'],
|
||
'fields' => ['type' => 'object'],
|
||
'featured_image' => ['type' => ['object', 'null']],
|
||
'rev' => ['type' => 'string'],
|
||
'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
]);
|
||
$schema['required'] = array_merge($schema['required'], ['content', 'terms', 'fields', 'rev', 'hints']);
|
||
|
||
return $schema;
|
||
}
|
||
|
||
/** @return array<string, list<array{id: int, name: string, slug: string}>> */
|
||
private function terms(WP_Post $post): array
|
||
{
|
||
$result = [];
|
||
foreach ($this->schema->taxonomies((string) $post->post_type) as $taxonomy) {
|
||
$terms = wp_get_post_terms((int) $post->ID, $taxonomy['name']);
|
||
if (! is_array($terms)) {
|
||
continue;
|
||
}
|
||
|
||
$result[$taxonomy['name']] = array_map(
|
||
static fn (object $term): array => [
|
||
'id' => (int) $term->term_id,
|
||
'name' => (string) $term->name,
|
||
'slug' => (string) $term->slug,
|
||
],
|
||
$terms
|
||
);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function fields(WP_Post $post): array
|
||
{
|
||
$result = [];
|
||
foreach ($this->schema->fields((string) $post->post_type) as $key => $field) {
|
||
$result[$key] = $this->schema->sourceOf($field)->read((int) $post->ID, $field);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/** @return array{id: int, url: string, alt: string}|null */
|
||
private function featuredImage(WP_Post $post): ?array
|
||
{
|
||
$id = (int) get_post_thumbnail_id($post);
|
||
if ($id === 0) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'id' => $id,
|
||
'url' => (string) wp_get_attachment_url($id),
|
||
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Content/ContentQueryAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
final class ContentQueryAbility implements Ability
|
||
{
|
||
private const STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
|
||
private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';
|
||
|
||
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/content-query';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Inhalte suchen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Sucht Einträge eines Inhaltstyps. Filter: Status, Volltext, Taxonomie-Begriffe (IDs oder Slugs), '
|
||
. 'Feldwert, Datum, übergeordneter Eintrag; sortierbar; Cursor-Paginierung. Liefert Kurzdaten '
|
||
. '(ID, Titel, Status, URLs, Divi ja/nein) – Details über lit-content-get.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge([
|
||
'post_type' => ['type' => 'string', 'description' => 'Inhaltstyp, z. B. page oder post.'],
|
||
'status' => [
|
||
'type' => 'array',
|
||
'items' => ['type' => 'string', 'enum' => self::STATUSES],
|
||
'description' => 'Standard: alle außer Papierkorb.',
|
||
],
|
||
'search' => ['type' => 'string'],
|
||
'taxonomy' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'taxonomy' => ['type' => 'string'],
|
||
'terms' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => ['integer', 'string']]],
|
||
],
|
||
'required' => ['taxonomy', 'terms'],
|
||
'additionalProperties' => false,
|
||
],
|
||
'field' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'key' => ['type' => 'string'],
|
||
'value' => ['type' => ['string', 'number', 'boolean']],
|
||
'compare' => ['type' => 'string', 'enum' => ['=', '!=', 'LIKE', 'EXISTS', 'NOT EXISTS']],
|
||
],
|
||
'required' => ['key'],
|
||
'additionalProperties' => false,
|
||
],
|
||
'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
|
||
'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
|
||
'parent' => ['type' => 'integer', 'minimum' => 0],
|
||
'orderby' => ['type' => 'string', 'enum' => ['date', 'modified', 'title', 'menu_order']],
|
||
'order' => ['type' => 'string', 'enum' => ['asc', 'desc']],
|
||
], Cursor::inputProperties()),
|
||
'required' => ['post_type'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'items' => ['type' => 'array', 'items' => PostPresenter::summarySchema()],
|
||
'next_cursor' => ['type' => ['string', 'null']],
|
||
],
|
||
'required' => ['items', 'next_cursor'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
|
||
|
||
return $type instanceof WP_Error ? $type : true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
|
||
if ($offset === null) {
|
||
return Cursor::invalid();
|
||
}
|
||
|
||
$limit = Cursor::limit($input['limit'] ?? null);
|
||
$posts = get_posts(array_merge($this->queryArgs($input), [
|
||
'posts_per_page' => $limit + 1,
|
||
'offset' => $offset,
|
||
]));
|
||
$page = Cursor::page(array_values($posts), $limit, $offset);
|
||
|
||
$items = [];
|
||
foreach ($page['items'] as $post) {
|
||
if ($post instanceof WP_Post && current_user_can('edit_post', (int) $post->ID)) {
|
||
$items[] = $this->presenter->summary($post);
|
||
}
|
||
}
|
||
|
||
return ['items' => $items, 'next_cursor' => $page['next_cursor']];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function queryArgs(array $input): array
|
||
{
|
||
$args = [
|
||
'post_type' => (string) $input['post_type'],
|
||
'post_status' => $input['status'] ?? self::STATUSES,
|
||
'orderby' => (string) ($input['orderby'] ?? 'date'),
|
||
'order' => strtoupper((string) ($input['order'] ?? 'desc')),
|
||
'no_found_rows' => true,
|
||
'ignore_sticky_posts' => true,
|
||
'suppress_filters' => false,
|
||
];
|
||
|
||
if (isset($input['search']) && $input['search'] !== '') {
|
||
$args['s'] = (string) $input['search'];
|
||
}
|
||
|
||
if (isset($input['parent'])) {
|
||
$args['post_parent'] = (int) $input['parent'];
|
||
}
|
||
|
||
if (isset($input['taxonomy']) && is_array($input['taxonomy'])) {
|
||
$args['tax_query'] = $this->taxQuery($input['taxonomy']);
|
||
}
|
||
|
||
if (isset($input['field']) && is_array($input['field'])) {
|
||
$compare = (string) ($input['field']['compare'] ?? '=');
|
||
$clause = ['key' => (string) $input['field']['key'], 'compare' => $compare];
|
||
if (! in_array($compare, ['EXISTS', 'NOT EXISTS'], true)) {
|
||
$clause['value'] = $input['field']['value'] ?? '';
|
||
}
|
||
$args['meta_query'] = [$clause];
|
||
}
|
||
|
||
$date = [];
|
||
if (isset($input['date_after'])) {
|
||
$date['after'] = (string) $input['date_after'];
|
||
}
|
||
if (isset($input['date_before'])) {
|
||
$date['before'] = (string) $input['date_before'];
|
||
}
|
||
if ($date !== []) {
|
||
$args['date_query'] = [array_merge($date, ['inclusive' => true])];
|
||
}
|
||
|
||
return $args;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $filter
|
||
* @return array<int|string, mixed>
|
||
*/
|
||
private function taxQuery(array $filter): array
|
||
{
|
||
$taxonomy = (string) $filter['taxonomy'];
|
||
$terms = (array) $filter['terms'];
|
||
$ids = array_values(array_filter($terms, 'is_int'));
|
||
$slugs = array_values(array_filter($terms, 'is_string'));
|
||
|
||
$clauses = [];
|
||
if ($ids !== []) {
|
||
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $ids];
|
||
}
|
||
if ($slugs !== []) {
|
||
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'slug', 'terms' => $slugs];
|
||
}
|
||
|
||
return count($clauses) > 1 ? array_merge(['relation' => 'OR'], $clauses) : $clauses;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Content/ContentGetAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
|
||
final class ContentGetAbility implements Ability
|
||
{
|
||
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/content-get';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Inhalt lesen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Liest einen Eintrag vollständig: Kerndaten, Inhalt, Begriffe, alle Felder (Pods/Meta), '
|
||
. 'Beitragsbild, rev (für lit-content-update). Bei Divi-Inhalten wird content nicht ausgegeben; '
|
||
. 'hints verweist dann auf lit-divi-outline.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
|
||
'required' => ['post_id'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return PostPresenter::fullSchema();
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
return $this->presenter->full($post);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen
|
||
```php
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||
```
|
||
und `abilities()` ersetzen:
|
||
```php
|
||
public function abilities(): array
|
||
{
|
||
$presenter = new PostPresenter($this->schema);
|
||
|
||
return [
|
||
new ContentTypesAbility($this->schema),
|
||
new ContentQueryAbility($this->schema, $presenter),
|
||
new ContentGetAbility($this->schema, $presenter),
|
||
];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: content-query and content-get tools
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 9: Schreiben – `PostWriter`, `lit/content-create`, `lit/content-update`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Content/PostWriter.php`, `src/Integrations/WordPress/Content/ContentCreateAbility.php`, `src/Integrations/WordPress/Content/ContentUpdateAbility.php`
|
||
- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Content/PostWriterTest.php`, `tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php`, `tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ContentSchema`, `FieldValidator`, `TermAssigner`, `PostGuard`, `RevisionPoint` (Tasks 4, 7), `PostPresenter::usesDivi()` (Task 8), `ChangeLog::record()` (Task 3), `ToolResult`, `Rev`, `Errors` (Task 1).
|
||
- Produces:
|
||
- `PostWriter` (nicht final): `__construct(ContentSchema $schema, FieldValidator $validator, TermAssigner $terms)`; `prepare(string $postType, array $input, bool $isCreate): array{fields: array, terms: array, warnings: list<string>}|WP_Error` (schreibt nichts; Fehlerpfade `fields.<key>`, `terms.<taxonomie>`, `featured_image`, `parent`); `postData(array $input): array` (Eingabe → `wp_insert_post`-Spalten `post_title`, `post_content`, `post_excerpt`, `post_name`, `post_parent`, `menu_order`, `page_template`); `hasExtras(array $input): bool`; `writeExtras(int $postId, string $postType, array $prepared, array $input): void` (Begriffe, Felder je Quelle, Beitragsbild; wirft `RuntimeException`); `static inputProperties(): array` (gemeinsame Input-Properties).
|
||
- `ContentCreateAbility`: `__construct(ContentSchema $schema, PostWriter $writer, ChangeLog $log)`; Name `lit/content-create`; Input `post_type`, `title` (Pflicht) + `inputProperties()`; kein `status`-Parameter; Output `ToolResult::schema()`; direkt; nicht idempotent.
|
||
- `ContentUpdateAbility`: gleicher Konstruktor; Name `lit/content-update`; Input `post_id` (Pflicht), `rev`, `title` + `inputProperties()`; Output `ToolResult::schema()`; direkt; idempotent.
|
||
- Action `lit_mcp_after_save` (`int $postId, string $ability, array $input`) nach jedem erfolgreichen Schreiben.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Content/PostWriterTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
|
||
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class PostWriterTest extends TestCase
|
||
{
|
||
public function testPrepareCollectsAllErrorsWithPaths(): void
|
||
{
|
||
Functions\when('get_object_taxonomies')->justReturn([]);
|
||
Functions\when('post_type_supports')->justReturn(false);
|
||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 3, 'post_type' => 'post']));
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('fields')->andReturn([
|
||
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
|
||
]);
|
||
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
|
||
|
||
$result = $writer->prepare('lit_person', [
|
||
'terms' => ['genre' => [1]],
|
||
'featured_image' => 9,
|
||
'parent' => 3,
|
||
], true);
|
||
|
||
$fields = $result->get_error_data()['fields'];
|
||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||
self::assertSame('Pflichtfeld fehlt.', $fields['fields.role']);
|
||
self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $fields['terms.genre']);
|
||
self::assertSame('Inhaltstyp unterstützt kein Beitragsbild.', $fields['featured_image']);
|
||
self::assertStringContainsString('denselben Inhaltstyp', $fields['parent']);
|
||
}
|
||
|
||
public function testPrepareReturnsValuesTermsAndWarnings(): void
|
||
{
|
||
Functions\when('get_object_taxonomies')->justReturn([]);
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('fields')->andReturn(['city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown')]);
|
||
|
||
$result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))
|
||
->prepare('page', ['fields' => ['city' => 'Trier']], false);
|
||
|
||
self::assertSame(['city' => 'Trier'], $result['fields']);
|
||
self::assertSame([], $result['terms']);
|
||
self::assertCount(1, $result['warnings']);
|
||
}
|
||
|
||
public function testPostDataMapsOnlyGivenKeys(): void
|
||
{
|
||
$writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner());
|
||
|
||
self::assertSame(
|
||
['post_title' => 'Team', 'post_name' => 'team', 'page_template' => ''],
|
||
$writer->postData(['title' => 'Team', 'slug' => 'team', 'template' => '', 'fields' => []])
|
||
);
|
||
self::assertTrue($writer->hasExtras(['featured_image' => null]));
|
||
self::assertFalse($writer->hasExtras(['title' => 'x', 'fields' => []]));
|
||
}
|
||
|
||
public function testWriteExtrasGroupsFieldsBySourceAndHandlesTheFeaturedImage(): void
|
||
{
|
||
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
|
||
$city = new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown');
|
||
$definitions = ['city' => $city, 'role' => $role];
|
||
$pods = Mockery::mock(FieldSource::class);
|
||
$pods->expects('write')->with(5, 'lit_person', ['role' => 'Leitung'], $definitions);
|
||
$sample = Mockery::mock(FieldSource::class);
|
||
$sample->expects('write')->with(5, 'lit_person', ['city' => 'Trier'], $definitions);
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('fields')->andReturn($definitions);
|
||
$schema->allows('sourceOf')->andReturnUsing(static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : $sample);
|
||
Functions\expect('delete_post_thumbnail')->once()->with(5);
|
||
|
||
(new PostWriter($schema, new FieldValidator(), new TermAssigner()))->writeExtras(
|
||
5,
|
||
'lit_person',
|
||
['fields' => ['role' => 'Leitung', 'city' => 'Trier'], 'terms' => [], 'warnings' => []],
|
||
['featured_image' => 0]
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||
|
||
use Brain\Monkey\Actions;
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class ContentCreateAbilityTest extends TestCase
|
||
{
|
||
private function schema(): ContentSchema
|
||
{
|
||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
|
||
return $schema;
|
||
}
|
||
|
||
public function testInvalidInputWritesNothing(): void
|
||
{
|
||
$writer = Mockery::mock(PostWriter::class);
|
||
$writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.']));
|
||
Functions\expect('wp_insert_post')->never();
|
||
|
||
$result = (new ContentCreateAbility($this->schema(), $writer, Mockery::mock(ChangeLog::class)))
|
||
->execute(['post_type' => 'lit_person', 'title' => 'Anna']);
|
||
|
||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||
}
|
||
|
||
public function testCreatesADraftWritesExtrasLogsAndReturnsAToolResult(): void
|
||
{
|
||
$schema = $this->schema();
|
||
$prepared = ['fields' => ['role' => 'Leitung'], 'terms' => [], 'warnings' => ['w']];
|
||
$input = ['post_type' => 'lit_person', 'title' => 'Anna', 'fields' => ['role' => 'Leitung']];
|
||
$writer = Mockery::mock(PostWriter::class);
|
||
$writer->allows('prepare')->with('lit_person', $input, true)->andReturn($prepared);
|
||
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
|
||
$writer->expects('writeExtras')->with(42, 'lit_person', $prepared, $input);
|
||
Functions\when('get_current_user_id')->justReturn(3);
|
||
Functions\expect('wp_insert_post')->once()->with([
|
||
'post_title' => 'Anna',
|
||
'post_type' => 'lit_person',
|
||
'post_status' => 'draft',
|
||
'post_author' => 3,
|
||
], true)->andReturn(42);
|
||
Actions\expectDone('lit_mcp_after_save')->once()->with(42, 'lit/content-create', $input);
|
||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_title' => 'Anna', 'post_type' => 'lit_person']));
|
||
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=42&preview=true');
|
||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=42&action=edit');
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->with('lit/content-create', 'lit_person', '42', 'Entwurf "Anna" angelegt')->andReturn(7);
|
||
|
||
$result = (new ContentCreateAbility($schema, $writer, $log))->execute($input);
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame([['id' => '42', 'type' => 'lit_person', 'action' => 'created', 'label' => 'Anna']], $result['changed']);
|
||
self::assertSame(7, $result['log_id']);
|
||
self::assertSame(['w'], $result['warnings']);
|
||
self::assertStringContainsString('Entwurf', $result['summary']);
|
||
self::assertNotNull($result['rev']);
|
||
}
|
||
|
||
public function testStatusIsNotAnInputAndPermissionNeedsCreateRights(): void
|
||
{
|
||
$schema = $this->schema();
|
||
$ability = new ContentCreateAbility($schema, Mockery::mock(PostWriter::class), Mockery::mock(ChangeLog::class));
|
||
|
||
self::assertArrayNotHasKey('status', $ability->inputSchema()['properties']);
|
||
self::assertFalse($ability->inputSchema()['additionalProperties']);
|
||
self::assertTrue($ability->permission(['post_type' => 'page']));
|
||
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
self::assertSame('lit_forbidden', $ability->permission(['post_type' => 'page'])->get_error_code());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||
|
||
use Brain\Monkey\Actions;
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class ContentUpdateAbilityTest extends TestCase
|
||
{
|
||
private WP_Post $post;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->post = new WP_Post([
|
||
'ID' => 5,
|
||
'post_type' => 'page',
|
||
'post_status' => 'publish',
|
||
'post_title' => 'Team',
|
||
'post_content' => '<p>Alt</p>',
|
||
]);
|
||
Functions\when('get_post')->justReturn($this->post);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
}
|
||
|
||
private function schema(): ContentSchema
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
|
||
return $schema;
|
||
}
|
||
|
||
public function testStaleRevIsRejectedWithoutWriting(): void
|
||
{
|
||
Functions\expect('wp_update_post')->never();
|
||
|
||
$result = (new ContentUpdateAbility($this->schema(), Mockery::mock(PostWriter::class), Mockery::mock(ChangeLog::class)))
|
||
->execute(['post_id' => 5, 'rev' => 'stale', 'title' => 'Neu']);
|
||
|
||
self::assertSame('lit_rev_mismatch', $result->get_error_code());
|
||
}
|
||
|
||
public function testRawContentOnDiviPagesIsUnsupported(): void
|
||
{
|
||
Functions\when('get_post_meta')->justReturn('on');
|
||
$writer = Mockery::mock(PostWriter::class);
|
||
$writer->expects('prepare')->never();
|
||
|
||
$result = (new ContentUpdateAbility($this->schema(), $writer, Mockery::mock(ChangeLog::class)))
|
||
->execute(['post_id' => 5, 'content' => '<p>Neu</p>']);
|
||
|
||
self::assertSame('lit_unsupported', $result->get_error_code());
|
||
self::assertStringContainsString('lit-divi-outline', $result->get_error_message());
|
||
}
|
||
|
||
public function testNothingToChangeIsAValidationError(): void
|
||
{
|
||
$writer = Mockery::mock(PostWriter::class);
|
||
$writer->allows('postData')->andReturn([]);
|
||
$writer->allows('hasExtras')->andReturn(false);
|
||
|
||
$result = (new ContentUpdateAbility($this->schema(), $writer, Mockery::mock(ChangeLog::class)))
|
||
->execute(['post_id' => 5]);
|
||
|
||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||
}
|
||
|
||
public function testUpdateKeepsTheStatusAndCapturesARestorePointFirst(): void
|
||
{
|
||
$order = [];
|
||
$prepared = ['fields' => [], 'terms' => [], 'warnings' => []];
|
||
$input = ['post_id' => 5, 'rev' => Rev::of('<p>Alt</p>'), 'title' => 'Unser Team'];
|
||
$writer = Mockery::mock(PostWriter::class);
|
||
$writer->allows('postData')->andReturn(['post_title' => 'Unser Team']);
|
||
$writer->allows('hasExtras')->andReturn(false);
|
||
$writer->allows('prepare')->with('page', $input, false)->andReturn($prepared);
|
||
$writer->expects('writeExtras')->with(5, 'page', $prepared, $input);
|
||
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
|
||
$order[] = 'revision';
|
||
return 811;
|
||
});
|
||
Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]);
|
||
Functions\expect('wp_update_post')->once()->with(['post_title' => 'Unser Team', 'ID' => 5], true)
|
||
->andReturnUsing(static function () use (&$order): int {
|
||
$order[] = 'update';
|
||
return 5;
|
||
});
|
||
Actions\expectDone('lit_mcp_after_save')->once()->with(5, 'lit/content-update', $input);
|
||
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/team/');
|
||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->with('lit/content-update', 'page', '5', Mockery::type('string'), 811)->andReturn(4711);
|
||
|
||
$result = (new ContentUpdateAbility($this->schema(), $writer, $log))->execute($input);
|
||
|
||
self::assertSame(['revision', 'update'], $order);
|
||
self::assertSame(811, $result['revision_id']);
|
||
self::assertSame(4711, $result['log_id']);
|
||
self::assertSame('updated', $result['changed'][0]['action']);
|
||
self::assertStringContainsString('Status unverändert: publish', $result['summary']);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `WordPressIntegrationTest` die Konstante ersetzen:
|
||
```php
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
'lit/content-query',
|
||
'lit/content-get',
|
||
'lit/content-create',
|
||
'lit/content-update',
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'PostWriterTest|ContentCreateAbilityTest|ContentUpdateAbilityTest|WordPressIntegrationTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Content/PostWriter.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
|
||
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* Shared validation and write logic of lit/content-create and lit/content-update.
|
||
* prepare() validates everything before the first write (all or nothing, spec §8.2).
|
||
* Not final so the abilities can be unit-tested with a mock.
|
||
*/
|
||
class PostWriter
|
||
{
|
||
private const POST_FIELDS = [
|
||
'title' => 'post_title',
|
||
'content' => 'post_content',
|
||
'excerpt' => 'post_excerpt',
|
||
'slug' => 'post_name',
|
||
'parent' => 'post_parent',
|
||
'menu_order' => 'menu_order',
|
||
'template' => 'page_template',
|
||
];
|
||
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private FieldValidator $validator,
|
||
private TermAssigner $terms
|
||
) {
|
||
}
|
||
|
||
/** @return array<string, array<string, mixed>> */
|
||
public static function inputProperties(): array
|
||
{
|
||
return [
|
||
'content' => ['type' => 'string', 'description' => 'HTML bzw. Block-Markup (nicht für Divi-Inhalte).'],
|
||
'excerpt' => ['type' => 'string'],
|
||
'slug' => ['type' => 'string'],
|
||
'parent' => ['type' => 'integer', 'minimum' => 0],
|
||
'menu_order' => ['type' => 'integer'],
|
||
'template' => ['type' => 'string', 'description' => 'Seiten-Template (Dateiname), leer = Standard.'],
|
||
'terms' => [
|
||
'type' => 'object',
|
||
'additionalProperties' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
|
||
'description' => 'Je Taxonomie die vollständige Liste der Begriffe (IDs oder Namen); '
|
||
. 'ersetzt die bisherige Zuordnung dieser Taxonomie.',
|
||
],
|
||
'fields' => [
|
||
'type' => 'object',
|
||
'description' => 'Feldwerte nach Schlüssel (siehe lit-content-types); null leert ein Feld.',
|
||
],
|
||
'featured_image' => [
|
||
'type' => ['integer', 'null'],
|
||
'minimum' => 0,
|
||
'description' => 'Anhang-ID des Beitragsbilds; 0 oder null entfernt es.',
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error Keys fields, terms (taxonomy => list<int|string>), warnings.
|
||
*/
|
||
public function prepare(string $postType, array $input, bool $isCreate): array|WP_Error
|
||
{
|
||
$errors = [];
|
||
|
||
$fields = $this->validator->validate(
|
||
$this->schema->fields($postType),
|
||
is_array($input['fields'] ?? null) ? $input['fields'] : [],
|
||
$isCreate
|
||
);
|
||
foreach ($fields['errors'] as $key => $message) {
|
||
$errors['fields.' . $key] = $message;
|
||
}
|
||
|
||
$terms = $this->terms->resolve($postType, is_array($input['terms'] ?? null) ? $input['terms'] : []);
|
||
foreach ($terms['errors'] as $taxonomy => $message) {
|
||
$errors['terms.' . $taxonomy] = $message;
|
||
}
|
||
|
||
$imageError = $this->checkFeaturedImage($postType, $input);
|
||
if ($imageError !== null) {
|
||
$errors['featured_image'] = $imageError;
|
||
}
|
||
|
||
$parentError = $this->checkParent($postType, $input);
|
||
if ($parentError !== null) {
|
||
$errors['parent'] = $parentError;
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $errors);
|
||
}
|
||
|
||
return ['fields' => $fields['values'], 'terms' => $terms['terms'], 'warnings' => $fields['warnings']];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function postData(array $input): array
|
||
{
|
||
$data = [];
|
||
foreach (self::POST_FIELDS as $key => $column) {
|
||
if (array_key_exists($key, $input)) {
|
||
$data[$column] = $input[$key];
|
||
}
|
||
}
|
||
|
||
return $data;
|
||
}
|
||
|
||
/** @param array<string, mixed> $input */
|
||
public function hasExtras(array $input): bool
|
||
{
|
||
return ! empty($input['fields']) || ! empty($input['terms']) || array_key_exists('featured_image', $input);
|
||
}
|
||
|
||
/**
|
||
* @param array{fields: array<string, mixed>, terms: array<string, list<int|string>>} $prepared
|
||
* @param array<string, mixed> $input
|
||
* @throws RuntimeException
|
||
*/
|
||
public function writeExtras(int $postId, string $postType, array $prepared, array $input): void
|
||
{
|
||
$this->terms->assign($postId, $prepared['terms']);
|
||
|
||
$definitions = $this->schema->fields($postType);
|
||
$bySource = [];
|
||
foreach ($prepared['fields'] as $key => $value) {
|
||
$bySource[$definitions[$key]->source][$key] = $value;
|
||
}
|
||
foreach ($bySource as $values) {
|
||
$first = $definitions[(string) array_key_first($values)];
|
||
$this->schema->sourceOf($first)->write($postId, $postType, $values, $definitions);
|
||
}
|
||
|
||
if (array_key_exists('featured_image', $input)) {
|
||
$imageId = (int) ($input['featured_image'] ?? 0);
|
||
if ($imageId > 0) {
|
||
set_post_thumbnail($postId, $imageId);
|
||
} else {
|
||
delete_post_thumbnail($postId);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @param array<string, mixed> $input */
|
||
private function checkFeaturedImage(string $postType, array $input): ?string
|
||
{
|
||
$imageId = (int) ($input['featured_image'] ?? 0);
|
||
if ($imageId === 0) {
|
||
return null;
|
||
}
|
||
|
||
if (! post_type_supports($postType, 'thumbnail')) {
|
||
return 'Inhaltstyp unterstützt kein Beitragsbild.';
|
||
}
|
||
|
||
return wp_attachment_is_image($imageId) ? null : 'Kein Bild aus der Mediathek.';
|
||
}
|
||
|
||
/** @param array<string, mixed> $input */
|
||
private function checkParent(string $postType, array $input): ?string
|
||
{
|
||
$parentId = (int) ($input['parent'] ?? 0);
|
||
if ($parentId === 0) {
|
||
return null;
|
||
}
|
||
|
||
$parent = get_post($parentId);
|
||
|
||
return $parent instanceof WP_Post && $parent->post_type === $postType
|
||
? null
|
||
: 'Übergeordneter Eintrag muss existieren und denselben Inhaltstyp haben.';
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Content/ContentCreateAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
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;
|
||
|
||
final class ContentCreateAbility implements Ability
|
||
{
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private PostWriter $writer,
|
||
private ChangeLog $log
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/content-create';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Inhalt anlegen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Legt einen neuen Eintrag an – immer als Entwurf (veröffentlichen nur über lit-post-publish). '
|
||
. 'Kerndaten, Begriffe, Felder (gegen das Schema aus lit-content-types geprüft) und Beitragsbild '
|
||
. 'in einem Schritt; bei einem Fehler wird nichts angelegt.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge([
|
||
'post_type' => ['type' => 'string'],
|
||
'title' => ['type' => 'string', 'minLength' => 1],
|
||
], PostWriter::inputProperties()),
|
||
'required' => ['post_type', 'title'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema();
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts');
|
||
|
||
return $type instanceof WP_Error ? $type : true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts');
|
||
if ($type instanceof WP_Error) {
|
||
return $type;
|
||
}
|
||
|
||
$postType = (string) $input['post_type'];
|
||
$prepared = $this->writer->prepare($postType, $input, true);
|
||
if ($prepared instanceof WP_Error) {
|
||
return $prepared;
|
||
}
|
||
|
||
$data = array_merge($this->writer->postData($input), [
|
||
'post_type' => $postType,
|
||
'post_status' => 'draft',
|
||
'post_author' => get_current_user_id(),
|
||
]);
|
||
$postId = wp_insert_post(wp_slash($data), true);
|
||
if ($postId instanceof WP_Error) {
|
||
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
|
||
}
|
||
|
||
$postId = (int) $postId;
|
||
$this->writer->writeExtras($postId, $postType, $prepared, $input);
|
||
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
|
||
|
||
$post = get_post($postId);
|
||
$title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title'];
|
||
$summary = sprintf('Entwurf "%s" angelegt', $title);
|
||
$logId = $this->log->record($this->name(), $postType, (string) $postId, $summary);
|
||
|
||
return ToolResult::ok(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId))
|
||
->changed((string) $postId, $postType, 'created', $title)
|
||
->rev(Rev::of($post instanceof WP_Post ? (string) $post->post_content : ''))
|
||
->logId($logId)
|
||
->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw'))
|
||
->warnings($prepared['warnings'])
|
||
->toArray();
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Content/ContentUpdateAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
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\RevisionPoint;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
final class ContentUpdateAbility implements Ability
|
||
{
|
||
private const CHANGE_KEYS = [
|
||
'title', 'content', 'excerpt', 'slug', 'parent', 'menu_order', 'template', 'terms', 'fields', 'featured_image',
|
||
];
|
||
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private PostWriter $writer,
|
||
private ChangeLog $log
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/content-update';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Inhalt ändern';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Ändert Kerndaten, Begriffe, Felder oder Beitragsbild eines Eintrags; der Status bleibt '
|
||
. 'unverändert (veröffentlichte Inhalte werden live geändert, vorher entsteht eine Revision). '
|
||
. 'rev aus lit-content-get mitgeben, um parallele Änderungen nicht zu überschreiben. '
|
||
. 'content von Divi-Seiten ist hier nicht änderbar.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge([
|
||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'rev' => ['type' => 'string', 'description' => 'rev aus lit-content-get (optimistische Sperre).'],
|
||
'title' => ['type' => 'string', 'minLength' => 1],
|
||
], PostWriter::inputProperties()),
|
||
'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 = 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;
|
||
}
|
||
|
||
if (isset($input['rev']) && $input['rev'] !== Rev::of((string) $post->post_content)) {
|
||
return Errors::revMismatch();
|
||
}
|
||
|
||
if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) {
|
||
return Errors::unsupported(
|
||
'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.',
|
||
'Divi-Struktur über lit-divi-outline und die lit-divi-*-Tools bearbeiten; Titel, Auszug, '
|
||
. 'Felder und Begriffe hier ohne content ändern.'
|
||
);
|
||
}
|
||
|
||
$data = $this->writer->postData($input);
|
||
if ($data === [] && ! $this->writer->hasExtras($input)) {
|
||
return Errors::invalidInput(
|
||
'Nichts zu ändern.',
|
||
[],
|
||
'Mindestens eines angeben: ' . implode(', ', self::CHANGE_KEYS) . '.'
|
||
);
|
||
}
|
||
|
||
$postType = (string) $post->post_type;
|
||
$prepared = $this->writer->prepare($postType, $input, false);
|
||
if ($prepared instanceof WP_Error) {
|
||
return $prepared;
|
||
}
|
||
|
||
$postId = (int) $post->ID;
|
||
$revisionId = RevisionPoint::capture($postId);
|
||
|
||
if ($data !== []) {
|
||
$result = wp_update_post(wp_slash(array_merge($data, ['ID' => $postId])), true);
|
||
if ($result instanceof WP_Error) {
|
||
return Errors::invalidInput('WordPress hat die Änderung abgelehnt: ' . $result->get_error_message());
|
||
}
|
||
}
|
||
|
||
$this->writer->writeExtras($postId, $postType, $prepared, $input);
|
||
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
|
||
|
||
$updated = get_post($postId);
|
||
$updated = $updated instanceof WP_Post ? $updated : $post;
|
||
$changedKeys = array_keys(array_intersect_key($input, array_flip(self::CHANGE_KEYS)));
|
||
$summary = sprintf('"%s" geändert: %s', $updated->post_title, implode(', ', $changedKeys));
|
||
$logId = $this->log->record($this->name(), $postType, (string) $postId, $summary, $revisionId);
|
||
|
||
return ToolResult::ok(sprintf('%s (Status unverändert: %s).', $summary, $updated->post_status))
|
||
->changed((string) $postId, $postType, 'updated', (string) $updated->post_title)
|
||
->rev(Rev::of((string) $updated->post_content))
|
||
->revisionId($revisionId)
|
||
->logId($logId)
|
||
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
|
||
->warnings($prepared['warnings'])
|
||
->toArray();
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen
|
||
```php
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
|
||
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
|
||
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
|
||
```
|
||
und `abilities()` ersetzen:
|
||
```php
|
||
public function abilities(): array
|
||
{
|
||
$presenter = new PostPresenter($this->schema);
|
||
$writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner());
|
||
|
||
return [
|
||
new ContentTypesAbility($this->schema),
|
||
new ContentQueryAbility($this->schema, $presenter),
|
||
new ContentGetAbility($this->schema, $presenter),
|
||
new ContentCreateAbility($this->schema, $writer, $this->log),
|
||
new ContentUpdateAbility($this->schema, $writer, $this->log),
|
||
];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: content-create and content-update with schema validation
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 10: Status – `lit/post-publish`, `lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`
|
||
|
||
Statuswechsel sind eigene, explizite Tools (Spec §2 Prinzip 5, §6.2). `post-trash` legt nur in den Papierkorb (Spec §10).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Status/PostPublishAbility.php`, `src/Integrations/WordPress/Status/PostUnpublishAbility.php`, `src/Integrations/WordPress/Status/PostScheduleAbility.php`, `src/Integrations/WordPress/Status/PostTrashAbility.php`
|
||
- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php`, `tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php`, `tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php`, `tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PostGuard`, `ContentSchema`, `ChangeLog`, `ToolResult`, `Errors`.
|
||
- Produces (alle `__construct(ContentSchema $schema, ChangeLog $log)`, Output `ToolResult::schema()`):
|
||
- `PostPublishAbility` – `lit/post-publish`, Input `{post_id}`; direkt; idempotent; Recht `publish_post`; setzt bei `future`/zukunftsdatiertem Eintrag das Datum auf jetzt; `changed.action` = `published`.
|
||
- `PostUnpublishAbility` – `lit/post-unpublish`, Input `{post_id}`; indirekt; idempotent; → `draft`; `changed.action` = `unpublished`.
|
||
- `PostScheduleAbility` – `lit/post-schedule`, Input `{post_id, date: "JJJJ-MM-TT HH:MM" (Ortszeit der Website)}`; indirekt; idempotent; Recht `publish_post`; → `future`; `changed.action` = `scheduled`.
|
||
- `PostTrashAbility` – `lit/post-trash`, `__construct(ContentSchema $schema, ChangeLog $log, ?callable $trashEnabled = null)` (Standard: `EMPTY_TRASH_DAYS` ≠ 0), Input `{post_id, confirm}`; indirekt; `destructive: true`, idempotent; Recht `delete_post`; `wp_trash_post()` nur bei aktivem Papierkorb, sonst `lit_unsupported`; `changed.action` = `trashed`.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php`:
|
||
```php
|
||
<?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());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php`:
|
||
```php
|
||
<?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']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php`:
|
||
```php
|
||
<?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());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php`:
|
||
```php
|
||
<?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());
|
||
}
|
||
}
|
||
```
|
||
|
||
In `WordPressIntegrationTest` die Konstante ersetzen:
|
||
```php
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
'lit/content-query',
|
||
'lit/content-get',
|
||
'lit/content-create',
|
||
'lit/content-update',
|
||
'lit/post-publish',
|
||
'lit/post-unpublish',
|
||
'lit/post-schedule',
|
||
'lit/post-trash',
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'Status|WordPressIntegrationTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Status/PostPublishAbility.php`:
|
||
```php
|
||
<?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\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;
|
||
|
||
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))
|
||
->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).'
|
||
);
|
||
}
|
||
|
||
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
|
||
$summary = sprintf('"%s" veröffentlicht', $title);
|
||
$logId = $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary);
|
||
|
||
return ToolResult::ok(sprintf('"%s" ist jetzt veröffentlicht.', $title))
|
||
->changed((string) $postId, (string) $post->post_type, 'published', $title)
|
||
->logId($logId)
|
||
->urls((string) get_permalink($postId), $editUrl)
|
||
->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();
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Status/PostUnpublishAbility.php`:
|
||
```php
|
||
<?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\ToolResult;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
|
||
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))
|
||
->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());
|
||
}
|
||
|
||
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
|
||
$summary = sprintf('"%s" zurückgezogen (vorher %s, jetzt Entwurf)', $title, $post->post_status);
|
||
$logId = $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary);
|
||
|
||
return ToolResult::ok($summary . '.')
|
||
->changed((string) $postId, (string) $post->post_type, 'unpublished', $title)
|
||
->logId($logId)
|
||
->urls((string) get_preview_post_link($post), $editUrl)
|
||
->toArray();
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Status/PostScheduleAbility.php`:
|
||
```php
|
||
<?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\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;
|
||
|
||
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).'
|
||
);
|
||
}
|
||
|
||
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
|
||
$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 = $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary);
|
||
|
||
return ToolResult::ok($summary . '.')
|
||
->changed((string) $postId, (string) $post->post_type, 'scheduled', $title)
|
||
->logId($logId)
|
||
->urls((string) get_preview_post_link($post), (string) get_edit_post_link($postId, 'raw'))
|
||
->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.');
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Status/PostTrashAbility.php`:
|
||
```php
|
||
<?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\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;
|
||
|
||
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))->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).'
|
||
);
|
||
}
|
||
|
||
$summary = sprintf('"%s" in den Papierkorb gelegt', $title);
|
||
$logId = $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary);
|
||
|
||
return ToolResult::ok(sprintf('"%s" liegt im Papierkorb und ist dort wiederherstellbar.', $title))
|
||
->changed((string) $postId, (string) $post->post_type, 'trashed', $title)
|
||
->logId($logId)
|
||
->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.');
|
||
}
|
||
}
|
||
```
|
||
|
||
Hinweis: `wp_trash_post()` löscht bei `EMPTY_TRASH_DAYS = 0` **endgültig** (`wp-includes/post.php`, `function wp_trash_post`: `if ( ! EMPTY_TRASH_DAYS ) return wp_delete_post( $post_id, true );`). Deshalb prüft das Tool vorher über das injizierbare `$trashEnabled` und lehnt ab, statt zu löschen.
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen
|
||
```php
|
||
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;
|
||
```
|
||
und in `abilities()` nach `new ContentUpdateAbility(...)` einfügen:
|
||
```php
|
||
new PostPublishAbility($this->schema, $this->log),
|
||
new PostUnpublishAbility($this->schema, $this->log),
|
||
new PostScheduleAbility($this->schema, $this->log),
|
||
new PostTrashAbility($this->schema, $this->log),
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS. Zeilen über 120 Zeichen (z. B. die `record(...)`-Aufrufe) für phpcs umbrechen.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "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"
|
||
```
|
||
|
||
---
|
||
### Task 11: Kopie und Austausch – `PostCloner`, `lit/post-duplicate`, `lit/post-replace-with-copy`
|
||
|
||
Spec §6.2: Entwurfskopie inkl. Meta, Begriffe, Divi-Flags; Austausch übernimmt Inhalt/Meta/Begriffe der Kopie ins Original (ID, URL, Menüeinträge bleiben), das Original erhält vorher eine Revision, die Kopie kommt in den Papierkorb.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Status/PostCloner.php`, `src/Integrations/WordPress/Status/PostDuplicateAbility.php`, `src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php`
|
||
- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Status/PostClonerTest.php`, `tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php`, `tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PostGuard`, `RevisionPoint`, `ContentSchema`, `ChangeLog`, `ToolResult`, `Errors`.
|
||
- Produces:
|
||
- `PostCloner` (nicht final): `COPY_OF_META = '_lit_mcp_copy_of'`; `duplicate(WP_Post $original): int` (Entwurf „<Titel> (Kopie)“, Autor = aktueller Benutzer, alle Meta außer `SKIP_META`, alle Begriffe, Markierung `_lit_mcp_copy_of`; wirft `RuntimeException`); `replace(WP_Post $original, WP_Post $copy): void` (Titel ohne „ (Kopie)“, Inhalt, Auszug; Meta des Originals außer `SKIP_META` gelöscht und durch die der Kopie ersetzt; Begriffe je Taxonomie ersetzt; Status, Slug, Datum, Autor des Originals bleiben).
|
||
- `PostDuplicateAbility`: `__construct(ContentSchema $schema, PostCloner $cloner, ChangeLog $log)`; `lit/post-duplicate`, Input `{post_id}`; indirekt; nicht idempotent; Ergebnis-Extra `original_id`.
|
||
- `PostReplaceWithCopyAbility`: `__construct(ContentSchema $schema, PostCloner $cloner, ChangeLog $log, ?callable $trashEnabled = null)`; `lit/post-replace-with-copy`, Input `{original_id, copy_id, confirm}`; indirekt; `destructive: true`, nicht idempotent; verlangt, dass die Kopie per `lit/post-duplicate` aus genau diesem Original entstand.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Status/PostClonerTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
final class PostClonerTest extends TestCase
|
||
{
|
||
private function original(): WP_Post
|
||
{
|
||
return new WP_Post([
|
||
'ID' => 5,
|
||
'post_type' => 'page',
|
||
'post_title' => 'Team',
|
||
'post_content' => '<!-- wp:divi/section /-->',
|
||
'post_excerpt' => 'Kurz',
|
||
'post_parent' => 2,
|
||
'menu_order' => 3,
|
||
]);
|
||
}
|
||
|
||
public function testDuplicateCopiesContentMetaAndTermsAsDraft(): void
|
||
{
|
||
Functions\when('get_current_user_id')->justReturn(4);
|
||
Functions\when('maybe_unserialize')->returnArg();
|
||
Functions\expect('wp_insert_post')->once()->with($this->draftMatcher(), true)->andReturn(77);
|
||
Functions\when('get_post_meta')->justReturn([
|
||
'_edit_lock' => ['123:1'],
|
||
'_et_pb_use_builder' => ['on'],
|
||
'role' => ['Leitung'],
|
||
]);
|
||
Functions\expect('add_post_meta')->once()->with(77, '_et_pb_use_builder', 'on');
|
||
Functions\expect('add_post_meta')->once()->with(77, 'role', 'Leitung');
|
||
Functions\when('get_object_taxonomies')->justReturn(['category']);
|
||
Functions\when('wp_get_object_terms')->justReturn([4, 9]);
|
||
Functions\expect('wp_set_object_terms')->once()->with(77, [4, 9], 'category', false);
|
||
Functions\expect('update_post_meta')->once()->with(77, '_lit_mcp_copy_of', 5);
|
||
|
||
self::assertSame(77, (new PostCloner())->duplicate($this->original()));
|
||
}
|
||
|
||
public function testDuplicateFailureThrows(): void
|
||
{
|
||
Functions\when('get_current_user_id')->justReturn(4);
|
||
Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'kaputt'));
|
||
|
||
$this->expectException(RuntimeException::class);
|
||
|
||
(new PostCloner())->duplicate($this->original());
|
||
}
|
||
|
||
public function testReplaceOverwritesContentMetaAndTermsButKeepsTheOriginalsIdentity(): void
|
||
{
|
||
$copy = new WP_Post([
|
||
'ID' => 77,
|
||
'post_type' => 'page',
|
||
'post_title' => 'Team (Kopie)',
|
||
'post_content' => 'neu',
|
||
'post_excerpt' => 'Neu kurz',
|
||
]);
|
||
Functions\when('maybe_unserialize')->returnArg();
|
||
Functions\expect('wp_update_post')->once()->with([
|
||
'ID' => 5,
|
||
'post_title' => 'Team',
|
||
'post_content' => 'neu',
|
||
'post_excerpt' => 'Neu kurz',
|
||
], true)->andReturn(5);
|
||
Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5
|
||
? ['_edit_lock' => ['1'], 'role' => ['Alt'], 'city' => ['Trier']]
|
||
: ['_lit_mcp_copy_of' => ['5'], 'role' => ['Neu']]);
|
||
Functions\expect('delete_post_meta')->once()->with(5, 'role');
|
||
Functions\expect('delete_post_meta')->once()->with(5, 'city');
|
||
Functions\expect('add_post_meta')->once()->with(5, 'role', 'Neu');
|
||
Functions\when('get_object_taxonomies')->justReturn(['category']);
|
||
Functions\when('wp_get_object_terms')->justReturn([12]);
|
||
Functions\expect('wp_set_object_terms')->once()->with(5, [12], 'category', false);
|
||
|
||
(new PostCloner())->replace($this->original(), $copy);
|
||
}
|
||
|
||
private function draftMatcher(): \Mockery\Matcher\Closure
|
||
{
|
||
return \Mockery::on(static fn (array $data): bool => $data['post_status'] === 'draft'
|
||
&& $data['post_type'] === 'page'
|
||
&& $data['post_title'] === 'Team (Kopie)'
|
||
&& $data['post_content'] === '<!-- wp:divi/section /-->'
|
||
&& $data['post_parent'] === 2
|
||
&& $data['menu_order'] === 3
|
||
&& $data['post_author'] === 4);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status;
|
||
|
||
use Brain\Monkey\Actions;
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||
use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class PostDuplicateAbilityTest extends TestCase
|
||
{
|
||
public function testCreatesADraftCopyAndReportsIt(): void
|
||
{
|
||
$original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team']);
|
||
Functions\when('get_post')->alias(static fn (int $id) => $id === 5
|
||
? $original
|
||
: new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)']));
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
|
||
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=77&preview=true');
|
||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=77&action=edit');
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
$cloner = Mockery::mock(PostCloner::class);
|
||
$cloner->expects('duplicate')->with($original)->andReturn(77);
|
||
Actions\expectDone('lit_mcp_after_save')->once()->with(77, 'lit/post-duplicate', ['post_id' => 5]);
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->with('lit/post-duplicate', 'page', '77', 'Kopie von "Team" (ID 5) angelegt')->andReturn(8);
|
||
|
||
$result = (new PostDuplicateAbility($schema, $cloner, $log))->execute(['post_id' => 5]);
|
||
|
||
self::assertSame([['id' => '77', 'type' => 'page', 'action' => 'created', 'label' => 'Team (Kopie)']], $result['changed']);
|
||
self::assertSame(5, $result['original_id']);
|
||
self::assertSame(8, $result['log_id']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php`:
|
||
```php
|
||
<?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\PostCloner;
|
||
use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class PostReplaceWithCopyAbilityTest extends TestCase
|
||
{
|
||
private WP_Post $original;
|
||
private WP_Post $copy;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish']);
|
||
$this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']);
|
||
Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('get_post_meta')->justReturn('5');
|
||
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');
|
||
}
|
||
|
||
private function ability(PostCloner $cloner, ChangeLog $log, bool $trash = true): PostReplaceWithCopyAbility
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
|
||
return new PostReplaceWithCopyAbility($schema, $cloner, $log, static fn (): bool => $trash);
|
||
}
|
||
|
||
public function testRequiresConfirmation(): void
|
||
{
|
||
$cloner = Mockery::mock(PostCloner::class);
|
||
$cloner->expects('replace')->never();
|
||
|
||
$result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute(['original_id' => 5, 'copy_id' => 77]);
|
||
|
||
self::assertSame('lit_confirm_required', $result->get_error_code());
|
||
}
|
||
|
||
public function testCopyMustStemFromTheOriginal(): void
|
||
{
|
||
Functions\when('get_post_meta')->justReturn('12');
|
||
$cloner = Mockery::mock(PostCloner::class);
|
||
$cloner->expects('replace')->never();
|
||
|
||
$result = $this->ability($cloner, Mockery::mock(ChangeLog::class))
|
||
->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]);
|
||
|
||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||
}
|
||
|
||
public function testDisabledTrashIsRefusedBeforeAnyChange(): void
|
||
{
|
||
$cloner = Mockery::mock(PostCloner::class);
|
||
$cloner->expects('replace')->never();
|
||
|
||
$result = $this->ability($cloner, Mockery::mock(ChangeLog::class), false)
|
||
->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]);
|
||
|
||
self::assertSame('lit_unsupported', $result->get_error_code());
|
||
}
|
||
|
||
public function testRevisionThenReplaceThenTrashTheCopy(): void
|
||
{
|
||
$order = [];
|
||
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
|
||
$order[] = 'revision';
|
||
return 900;
|
||
});
|
||
Functions\when('wp_get_post_revisions')->justReturn([900 => new WP_Post(['ID' => 900])]);
|
||
$cloner = Mockery::mock(PostCloner::class);
|
||
$cloner->expects('replace')->with($this->original, $this->copy)->andReturnUsing(static function () use (&$order): void {
|
||
$order[] = 'replace';
|
||
});
|
||
Functions\expect('wp_trash_post')->once()->with(77)->andReturnUsing(static function () use (&$order): WP_Post {
|
||
$order[] = 'trash';
|
||
return new WP_Post(['ID' => 77]);
|
||
});
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900)->andReturn(11);
|
||
|
||
$result = $this->ability($cloner, $log)->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]);
|
||
|
||
self::assertSame(['revision', 'replace', 'trash'], $order);
|
||
self::assertSame(900, $result['revision_id']);
|
||
self::assertSame(['replaced', 'trashed'], array_column($result['changed'], 'action'));
|
||
}
|
||
}
|
||
```
|
||
|
||
In `WordPressIntegrationTest` die Konstante ersetzen:
|
||
```php
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
'lit/content-query',
|
||
'lit/content-get',
|
||
'lit/content-create',
|
||
'lit/content-update',
|
||
'lit/post-publish',
|
||
'lit/post-unpublish',
|
||
'lit/post-schedule',
|
||
'lit/post-trash',
|
||
'lit/post-duplicate',
|
||
'lit/post-replace-with-copy',
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'PostCloner|PostDuplicate|PostReplaceWithCopy|WordPressIntegrationTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Status/PostCloner.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* Copies entries (draft copy) and swaps a copy's content back into its original.
|
||
* Meta is copied raw (incl. Divi flags and Pods meta); Pods relationships stored in
|
||
* the meta table travel with it. Not final so abilities can be tested with a mock.
|
||
*/
|
||
class PostCloner
|
||
{
|
||
public const COPY_OF_META = '_lit_mcp_copy_of';
|
||
|
||
/** Bookkeeping meta that belongs to one post and must never be copied or cleared. */
|
||
private const SKIP_META = [
|
||
'_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date', '_wp_trash_meta_status',
|
||
'_wp_trash_meta_time', '_wp_desired_post_slug', '_encloseme', '_pingme', self::COPY_OF_META,
|
||
];
|
||
|
||
/** @throws RuntimeException */
|
||
public function duplicate(WP_Post $original): int
|
||
{
|
||
$copyId = wp_insert_post(wp_slash([
|
||
'post_type' => (string) $original->post_type,
|
||
'post_status' => 'draft',
|
||
'post_title' => sprintf('%s (Kopie)', $original->post_title),
|
||
'post_content' => (string) $original->post_content,
|
||
'post_excerpt' => (string) $original->post_excerpt,
|
||
'post_parent' => (int) $original->post_parent,
|
||
'menu_order' => (int) $original->menu_order,
|
||
'comment_status' => (string) $original->comment_status,
|
||
'ping_status' => (string) $original->ping_status,
|
||
'post_password' => (string) $original->post_password,
|
||
'post_author' => get_current_user_id(),
|
||
]), true);
|
||
|
||
if ($copyId instanceof WP_Error || (int) $copyId === 0) {
|
||
$reason = $copyId instanceof WP_Error ? $copyId->get_error_message() : 'unknown';
|
||
throw new RuntimeException('Copy could not be created: ' . $reason);
|
||
}
|
||
|
||
$copyId = (int) $copyId;
|
||
$this->copyMeta((int) $original->ID, $copyId);
|
||
$this->copyTerms($original, $copyId);
|
||
update_post_meta($copyId, self::COPY_OF_META, (int) $original->ID);
|
||
|
||
return $copyId;
|
||
}
|
||
|
||
/** @throws RuntimeException */
|
||
public function replace(WP_Post $original, WP_Post $copy): void
|
||
{
|
||
$result = wp_update_post(wp_slash([
|
||
'ID' => (int) $original->ID,
|
||
'post_title' => (string) preg_replace('/ \(Kopie\)$/u', '', (string) $copy->post_title),
|
||
'post_content' => (string) $copy->post_content,
|
||
'post_excerpt' => (string) $copy->post_excerpt,
|
||
]), true);
|
||
if ($result instanceof WP_Error) {
|
||
throw new RuntimeException('Original could not be updated: ' . $result->get_error_message());
|
||
}
|
||
|
||
foreach (array_keys((array) get_post_meta((int) $original->ID)) as $key) {
|
||
if (! in_array((string) $key, self::SKIP_META, true)) {
|
||
delete_post_meta((int) $original->ID, (string) $key);
|
||
}
|
||
}
|
||
|
||
$this->copyMeta((int) $copy->ID, (int) $original->ID);
|
||
$this->copyTerms($copy, (int) $original->ID);
|
||
}
|
||
|
||
private function copyMeta(int $from, int $to): void
|
||
{
|
||
foreach ((array) get_post_meta($from) as $key => $values) {
|
||
if (in_array((string) $key, self::SKIP_META, true)) {
|
||
continue;
|
||
}
|
||
|
||
foreach ((array) $values as $value) {
|
||
add_post_meta($to, (string) $key, wp_slash(maybe_unserialize($value)));
|
||
}
|
||
}
|
||
}
|
||
|
||
private function copyTerms(WP_Post $from, int $to): void
|
||
{
|
||
foreach (get_object_taxonomies((string) $from->post_type) as $taxonomy) {
|
||
$ids = wp_get_object_terms((int) $from->ID, $taxonomy, ['fields' => 'ids']);
|
||
if (is_array($ids)) {
|
||
wp_set_object_terms($to, array_map('intval', $ids), $taxonomy, false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Status/PostDuplicateAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
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;
|
||
|
||
final class PostDuplicateAbility implements Ability
|
||
{
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private PostCloner $cloner,
|
||
private ChangeLog $log
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/post-duplicate';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Entwurfskopie anlegen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Legt eine Entwurfskopie eines Eintrags an (Inhalt, Felder/Meta, Begriffe, Divi-Einstellungen). '
|
||
. 'Typischer Ablauf: Kopie bearbeiten, prüfen, dann mit lit-post-replace-with-copy ins Original '
|
||
. 'übernehmen – so bleibt die veröffentlichte Seite bis dahin unverändert.';
|
||
}
|
||
|
||
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(['original_id' => ['type' => 'integer']], ['original_id']);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||
}
|
||
|
||
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
|
||
{
|
||
$original = $this->load($input);
|
||
if ($original instanceof WP_Error) {
|
||
return $original;
|
||
}
|
||
|
||
$copyId = $this->cloner->duplicate($original);
|
||
do_action('lit_mcp_after_save', $copyId, $this->name(), $input);
|
||
|
||
$copy = get_post($copyId);
|
||
$copyTitle = $copy instanceof WP_Post ? (string) $copy->post_title : '';
|
||
$postType = (string) $original->post_type;
|
||
$summary = sprintf('Kopie von "%s" (ID %d) angelegt', $original->post_title, $original->ID);
|
||
$logId = $this->log->record($this->name(), $postType, (string) $copyId, $summary);
|
||
|
||
return ToolResult::ok(sprintf('%s: Entwurf ID %d.', $summary, $copyId))
|
||
->changed((string) $copyId, $postType, 'created', $copyTitle)
|
||
->logId($logId)
|
||
->urls((string) get_preview_post_link($copyId), (string) get_edit_post_link($copyId, 'raw'))
|
||
->with('original_id', (int) $original->ID)
|
||
->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;
|
||
}
|
||
|
||
$type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts');
|
||
|
||
return $type instanceof WP_Error ? $type : $post;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php`:
|
||
```php
|
||
<?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\ToolResult;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\RevisionPoint;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
final class PostReplaceWithCopyAbility implements Ability
|
||
{
|
||
/** @var callable(): bool */
|
||
private $trashEnabled;
|
||
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private PostCloner $cloner,
|
||
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-replace-with-copy';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Kopie ins Original übernehmen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Übernimmt Titel, Inhalt, Felder/Meta und Begriffe einer mit lit-post-duplicate erstellten Kopie '
|
||
. 'ins Original. ID, URL, Status und Menüeinträge des Originals bleiben; vorher entsteht eine '
|
||
. 'Revision des Originals, die Kopie kommt danach in den Papierkorb. Erst nach Rückfrage mit '
|
||
. 'confirm: true aufrufen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'original_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'copy_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'confirm' => ['type' => 'boolean'],
|
||
],
|
||
'required' => ['original_id', 'copy_id'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema();
|
||
}
|
||
|
||
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
|
||
{
|
||
$pair = $this->load($input);
|
||
|
||
return $pair instanceof WP_Error ? $pair : true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$pair = $this->load($input);
|
||
if ($pair instanceof WP_Error) {
|
||
return $pair;
|
||
}
|
||
|
||
[$original, $copy] = $pair;
|
||
$originalId = (int) $original->ID;
|
||
$copyId = (int) $copy->ID;
|
||
|
||
if (($input['confirm'] ?? false) !== true) {
|
||
return Errors::confirmRequired(sprintf(
|
||
'Inhalt von "%s" (ID %d) durch die Kopie ID %d ersetzen und die Kopie in den Papierkorb legen?',
|
||
$original->post_title,
|
||
$originalId,
|
||
$copyId
|
||
));
|
||
}
|
||
|
||
$copyOf = (int) get_post_meta($copyId, PostCloner::COPY_OF_META, true);
|
||
if ($copy->post_type !== $original->post_type || $copyOf !== $originalId) {
|
||
return Errors::invalidInput(
|
||
'Die Kopie gehört nicht zu diesem Original.',
|
||
['copy_id' => sprintf('keine mit lit-post-duplicate erstellte Kopie von %d', $originalId)],
|
||
'Kopie mit lit-post-duplicate aus diesem Original erstellen.'
|
||
);
|
||
}
|
||
|
||
if (! ($this->trashEnabled)()) {
|
||
return Errors::unsupported(
|
||
'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. '
|
||
. 'Nichts wurde geändert.',
|
||
'Den Austausch in WordPress manuell durchführen.'
|
||
);
|
||
}
|
||
|
||
$revisionId = RevisionPoint::capture($originalId);
|
||
$this->cloner->replace($original, $copy);
|
||
wp_trash_post($copyId);
|
||
do_action('lit_mcp_after_save', $originalId, $this->name(), $input);
|
||
|
||
$title = (string) preg_replace('/ \(Kopie\)$/u', '', (string) $copy->post_title);
|
||
$postType = (string) $original->post_type;
|
||
$summary = sprintf('Kopie ID %d in "%s" (ID %d) übernommen', $copyId, $title, $originalId);
|
||
$logId = $this->log->record($this->name(), $postType, (string) $originalId, $summary, $revisionId);
|
||
|
||
$statusNote = sprintf('; Status bleibt %s, Kopie liegt im Papierkorb.', $original->post_status);
|
||
|
||
return ToolResult::ok($summary . $statusNote)
|
||
->changed((string) $originalId, $postType, 'replaced', $title)
|
||
->changed((string) $copyId, $postType, 'trashed', (string) $copy->post_title)
|
||
->revisionId($revisionId)
|
||
->logId($logId)
|
||
->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw'))
|
||
->toArray();
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array{0: WP_Post, 1: WP_Post}|WP_Error
|
||
*/
|
||
private function load(array $input): array|WP_Error
|
||
{
|
||
$original = PostGuard::editable($input['original_id'] ?? null, $this->schema, 'original_id');
|
||
if ($original instanceof WP_Error) {
|
||
return $original;
|
||
}
|
||
|
||
$copy = PostGuard::editable($input['copy_id'] ?? null, $this->schema, 'copy_id');
|
||
if ($copy instanceof WP_Error) {
|
||
return $copy;
|
||
}
|
||
|
||
if (! current_user_can('delete_post', (int) $copy->ID)) {
|
||
return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.');
|
||
}
|
||
|
||
return [$original, $copy];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen
|
||
```php
|
||
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||
use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility;
|
||
use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility;
|
||
```
|
||
und in `abilities()` vor dem `return` `$cloner = new PostCloner();` ergänzen sowie nach `new PostTrashAbility(...)` einfügen:
|
||
```php
|
||
new PostDuplicateAbility($this->schema, $cloner, $this->log),
|
||
new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS (lange Zeilen für phpcs umbrechen).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: draft copies and replace-with-copy
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Revisionen – `lit/revisions-list`, `lit/revision-restore`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Revisions/RevisionsListAbility.php`, `src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php`
|
||
- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php`, `tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PostGuard`, `RevisionPoint`, `Cursor`, `Rev`, `ChangeLog`, `ToolResult`, `Errors`.
|
||
- Produces:
|
||
- `RevisionsListAbility`: `__construct(ContentSchema $schema)`; `lit/revisions-list`, Input `{post_id, limit?, cursor?}`, Output `{post_id, items: list<{id, date, author{id,name}, title, autosave, excerpt}>, next_cursor}`; indirekt; readonly.
|
||
- `RevisionRestoreAbility`: `__construct(ContentSchema $schema, ChangeLog $log)`; `lit/revision-restore`, Input `{revision_id, confirm}`; indirekt; `destructive: true`, idempotent; sichert den aktuellen Stand vorher als Revision (`revision_id` im Ergebnis = dieser Wiederherstellungspunkt); Status unverändert.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Revisions;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class RevisionsListAbilityTest extends TestCase
|
||
{
|
||
public function testListsRevisionsNewestFirstWithCursor(): void
|
||
{
|
||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page']));
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('get_the_author_meta')->justReturn('Jan');
|
||
Functions\when('wp_is_post_autosave')->alias(static fn ($post): bool => $post->ID === 811);
|
||
Functions\when('wp_strip_all_tags')->alias(static fn (string $text): string => strip_tags($text));
|
||
Functions\expect('wp_get_post_revisions')->once()->with(5, ['posts_per_page' => 3, 'offset' => 0])->andReturn([
|
||
812 => new WP_Post(['ID' => 812, 'post_title' => 'Team', 'post_content' => '<p>Neu</p>', 'post_author' => '2']),
|
||
811 => new WP_Post(['ID' => 811, 'post_title' => 'Team', 'post_content' => '<p>Auto</p>', 'post_author' => '2']),
|
||
810 => new WP_Post(['ID' => 810, 'post_title' => 'Team', 'post_content' => '<p>Alt</p>', 'post_author' => '2']),
|
||
]);
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
|
||
$result = (new RevisionsListAbility($schema))->execute(['post_id' => 5, 'limit' => 2]);
|
||
|
||
self::assertSame(5, $result['post_id']);
|
||
self::assertSame([812, 811], array_column($result['items'], 'id'));
|
||
self::assertSame('Neu', $result['items'][0]['excerpt']);
|
||
self::assertTrue($result['items'][1]['autosave']);
|
||
self::assertSame(['id' => 2, 'name' => 'Jan'], $result['items'][0]['author']);
|
||
self::assertSame(2, Cursor::decode($result['next_cursor']));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Revisions;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class RevisionRestoreAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('get_post')->alias(static fn (int $id) => match ($id) {
|
||
810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5, 'post_date' => '2026-09-20 10:00:00']),
|
||
5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish', 'post_content' => 'alt']),
|
||
default => null,
|
||
});
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
}
|
||
|
||
private function ability(ChangeLog $log): RevisionRestoreAbility
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
|
||
return new RevisionRestoreAbility($schema, $log);
|
||
}
|
||
|
||
public function testNonRevisionIdIsNotFound(): void
|
||
{
|
||
self::assertSame('lit_not_found', $this->ability(Mockery::mock(ChangeLog::class))->permission(['revision_id' => 5])->get_error_code());
|
||
}
|
||
|
||
public function testRequiresConfirmation(): void
|
||
{
|
||
Functions\expect('wp_restore_post_revision')->never();
|
||
|
||
$result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['revision_id' => 810]);
|
||
|
||
self::assertSame('lit_confirm_required', $result->get_error_code());
|
||
}
|
||
|
||
public function testSavesTheCurrentStateThenRestores(): void
|
||
{
|
||
$order = [];
|
||
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
|
||
$order[] = 'backup';
|
||
return 813;
|
||
});
|
||
Functions\when('wp_get_post_revisions')->justReturn([813 => new WP_Post(['ID' => 813])]);
|
||
Functions\expect('wp_restore_post_revision')->once()->with(810)->andReturnUsing(static function () use (&$order): int {
|
||
$order[] = 'restore';
|
||
return 5;
|
||
});
|
||
Functions\when('get_preview_post_link')->justReturn('https://kunde.de/team/');
|
||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit');
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->with('lit/revision-restore', 'page', '5', Mockery::type('string'), 813)->andReturn(12);
|
||
|
||
$result = $this->ability($log)->execute(['revision_id' => 810, 'confirm' => true]);
|
||
|
||
self::assertSame(['backup', 'restore'], $order);
|
||
self::assertSame(813, $result['revision_id']);
|
||
self::assertSame('restored', $result['changed'][0]['action']);
|
||
self::assertStringContainsString('2026-09-20 10:00:00', $result['summary']);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `WordPressIntegrationTest` die Konstante um `'lit/revisions-list', 'lit/revision-restore'` (nach `'lit/post-replace-with-copy'`) ergänzen:
|
||
```php
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
'lit/content-query',
|
||
'lit/content-get',
|
||
'lit/content-create',
|
||
'lit/content-update',
|
||
'lit/post-publish',
|
||
'lit/post-unpublish',
|
||
'lit/post-schedule',
|
||
'lit/post-trash',
|
||
'lit/post-duplicate',
|
||
'lit/post-replace-with-copy',
|
||
'lit/revisions-list',
|
||
'lit/revision-restore',
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'Revision|WordPressIntegrationTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Revisions/RevisionsListAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Revisions;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
final class RevisionsListAbility implements Ability
|
||
{
|
||
public function __construct(private ContentSchema $schema)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/revisions-list';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Revisionen auflisten';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Listet die gespeicherten Revisionen eines Eintrags (neueste zuerst) mit Datum, Autor und '
|
||
. 'Textauszug. Wiederherstellen mit lit-revision-restore.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge(
|
||
['post_id' => ['type' => 'integer', 'minimum' => 1]],
|
||
Cursor::inputProperties()
|
||
),
|
||
'required' => ['post_id'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_id' => ['type' => 'integer'],
|
||
'items' => [
|
||
'type' => 'array',
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'integer'],
|
||
'date' => ['type' => 'string'],
|
||
'author' => ['type' => 'object'],
|
||
'title' => ['type' => 'string'],
|
||
'autosave' => ['type' => 'boolean'],
|
||
'excerpt' => ['type' => 'string'],
|
||
],
|
||
'required' => ['id', 'date', 'author', 'title', 'autosave', 'excerpt'],
|
||
],
|
||
],
|
||
'next_cursor' => ['type' => ['string', 'null']],
|
||
],
|
||
'required' => ['post_id', 'items', 'next_cursor'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, '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;
|
||
}
|
||
|
||
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
|
||
if ($offset === null) {
|
||
return Cursor::invalid();
|
||
}
|
||
|
||
$limit = Cursor::limit($input['limit'] ?? null);
|
||
$revisions = wp_get_post_revisions((int) $post->ID, ['posts_per_page' => $limit + 1, 'offset' => $offset]);
|
||
$page = Cursor::page(array_values(is_array($revisions) ? $revisions : []), $limit, $offset);
|
||
|
||
return [
|
||
'post_id' => (int) $post->ID,
|
||
'items' => array_map(fn (WP_Post $revision): array => $this->item($revision), $page['items']),
|
||
'next_cursor' => $page['next_cursor'],
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function item(WP_Post $revision): array
|
||
{
|
||
$text = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags((string) $revision->post_content)));
|
||
|
||
return [
|
||
'id' => (int) $revision->ID,
|
||
'date' => (string) $revision->post_date,
|
||
'author' => [
|
||
'id' => (int) $revision->post_author,
|
||
'name' => (string) get_the_author_meta('display_name', (int) $revision->post_author),
|
||
],
|
||
'title' => (string) $revision->post_title,
|
||
'autosave' => (bool) wp_is_post_autosave($revision),
|
||
'excerpt' => mb_substr($text, 0, 160),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Revisions;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
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\RevisionPoint;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
final class RevisionRestoreAbility implements Ability
|
||
{
|
||
public function __construct(private ContentSchema $schema, private ChangeLog $log)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/revision-restore';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Revision wiederherstellen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Stellt Titel, Inhalt und Auszug eines Eintrags aus einer Revision wieder her (Status bleibt). '
|
||
. 'Der aktuelle Stand wird vorher als Revision gesichert (revision_id im Ergebnis). Erst nach '
|
||
. 'Rückfrage mit confirm: true aufrufen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'revision_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'confirm' => ['type' => 'boolean'],
|
||
],
|
||
'required' => ['revision_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
|
||
{
|
||
$pair = $this->load($input);
|
||
|
||
return $pair instanceof WP_Error ? $pair : true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$pair = $this->load($input);
|
||
if ($pair instanceof WP_Error) {
|
||
return $pair;
|
||
}
|
||
|
||
[$revision, $post] = $pair;
|
||
$postId = (int) $post->ID;
|
||
if (($input['confirm'] ?? false) !== true) {
|
||
return Errors::confirmRequired(sprintf(
|
||
'"%s" (ID %d) auf den Stand vom %s zurücksetzen?',
|
||
$post->post_title,
|
||
$postId,
|
||
$revision->post_date
|
||
));
|
||
}
|
||
|
||
$backupId = RevisionPoint::capture($postId);
|
||
$restored = wp_restore_post_revision((int) $revision->ID);
|
||
if (! $restored) {
|
||
return Errors::unsupported(
|
||
'WordPress konnte die Revision nicht wiederherstellen.',
|
||
'Revision in WordPress unter „Revisionen“ prüfen.'
|
||
);
|
||
}
|
||
|
||
do_action('lit_mcp_after_save', $postId, $this->name(), $input);
|
||
$updated = get_post($postId);
|
||
$updated = $updated instanceof WP_Post ? $updated : $post;
|
||
$summary = sprintf('"%s" auf Stand vom %s zurückgesetzt', $updated->post_title, $revision->post_date);
|
||
$postType = (string) $post->post_type;
|
||
$logId = $this->log->record($this->name(), $postType, (string) $postId, $summary, $backupId);
|
||
|
||
return ToolResult::ok($summary . sprintf(' (Status unverändert: %s).', $updated->post_status))
|
||
->changed((string) $postId, $postType, 'restored', (string) $updated->post_title)
|
||
->rev(Rev::of((string) $updated->post_content))
|
||
->revisionId($backupId)
|
||
->logId($logId)
|
||
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
|
||
->toArray();
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array{0: WP_Post, 1: WP_Post}|WP_Error
|
||
*/
|
||
private function load(array $input): array|WP_Error
|
||
{
|
||
$revisionId = PostGuard::id($input['revision_id'] ?? null);
|
||
$revision = $revisionId === null ? null : get_post($revisionId);
|
||
if (! $revision instanceof WP_Post || $revision->post_type !== 'revision') {
|
||
return Errors::notFound('Revision nicht gefunden.', 'Revisionen über lit-revisions-list abrufen.');
|
||
}
|
||
|
||
$post = PostGuard::editable((int) $revision->post_parent, $this->schema);
|
||
|
||
return $post instanceof WP_Error ? $post : [$revision, $post];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen
|
||
```php
|
||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
|
||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
|
||
```
|
||
und in `abilities()` nach `new PostReplaceWithCopyAbility(...)` einfügen:
|
||
```php
|
||
new RevisionsListAbility($this->schema),
|
||
new RevisionRestoreAbility($this->schema, $this->log),
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: list and restore revisions
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 13: Menüs lesen, anlegen, Positionen – `lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-assign-location`
|
||
|
||
Recht für alle Menü-Tools: `edit_theme_options` (Spec §4.5).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Menus/MenuTree.php`, `src/Integrations/WordPress/Menus/MenuLocations.php`, `src/Integrations/WordPress/Menus/MenuListAbility.php`, `src/Integrations/WordPress/Menus/MenuGetAbility.php`, `src/Integrations/WordPress/Menus/MenuCreateAbility.php`, `src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php`
|
||
- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ChangeLog`, `ToolResult`, `Errors`.
|
||
- Produces:
|
||
- `MenuTree` (final, statisch): `build(list<object> $items): list<Node>` mit `Node = {id: int, title: string, type: 'post'|'taxonomy'|'custom', object: string, object_id: int, url: string, children: list<Node>}` (Einträge mit fehlendem Elternteil landen auf oberster Ebene); `node(object $item): array` (ohne `children`); `nodeSchema(): array`.
|
||
- `MenuLocations` (final, statisch): `registered(): array<string, string>`, `assigned(): array<string, int>`, `of(int $menuId): list<string>`, `assign(string $location, int $menuId): void` (`0` = Zuordnung entfernen).
|
||
- `MenuListAbility()` – `lit/menu-list`, Output `{menus: list<{id, name, slug, count, locations}>, locations: list<{location, description, menu_id|null}>}`; indirekt; readonly.
|
||
- `MenuGetAbility()` – `lit/menu-get`, Input `{menu_id}`, Output `{id, name, locations, items: list<Node>}`; indirekt; readonly.
|
||
- `MenuCreateAbility(ChangeLog $log)` – `lit/menu-create`, Input `{name, location?}`; Ergebnis-Extra `menu_id`; indirekt; nicht idempotent.
|
||
- `MenuAssignLocationAbility(ChangeLog $log)` – `lit/menu-assign-location`, Input `{location, menu_id}` (`0` entfernt); Ergebnis-Extra `previous_menu_id` (int|null); indirekt; idempotent.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuTree;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class MenuTreeTest extends TestCase
|
||
{
|
||
public static function item(int $id, int $parent, string $title, string $type = 'post_type', string $url = ''): object
|
||
{
|
||
return (object) [
|
||
'ID' => $id,
|
||
'menu_item_parent' => (string) $parent,
|
||
'title' => $title,
|
||
'post_title' => $type === 'custom' ? $title : '',
|
||
'type' => $type,
|
||
'object' => $type === 'custom' ? 'custom' : 'page',
|
||
'object_id' => (string) ($type === 'custom' ? $id : $id + 100),
|
||
'url' => $url,
|
||
'description' => '',
|
||
'attr_title' => '',
|
||
'target' => '',
|
||
'classes' => [''],
|
||
'xfn' => '',
|
||
];
|
||
}
|
||
|
||
public function testBuildsANestedTreeInMenuOrder(): void
|
||
{
|
||
$tree = MenuTree::build([
|
||
self::item(10, 0, 'Start'),
|
||
self::item(11, 0, 'Über uns'),
|
||
self::item(12, 11, 'Team'),
|
||
self::item(13, 0, 'Kontakt', 'custom', '/kontakt'),
|
||
]);
|
||
|
||
self::assertSame(['Start', 'Über uns', 'Kontakt'], array_column($tree, 'title'));
|
||
self::assertSame('Team', $tree[1]['children'][0]['title']);
|
||
self::assertSame([], $tree[1]['children'][0]['children']);
|
||
self::assertSame('custom', $tree[2]['type']);
|
||
self::assertSame('post', $tree[0]['type']);
|
||
self::assertSame(110, $tree[0]['object_id']);
|
||
}
|
||
|
||
public function testOrphansAndSelfParentsLandOnTheTopLevel(): void
|
||
{
|
||
$tree = MenuTree::build([self::item(20, 99, 'Waise'), self::item(21, 21, 'Zirkel')]);
|
||
|
||
self::assertSame(['Waise', 'Zirkel'], array_column($tree, 'title'));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class MenuReadAbilitiesTest extends TestCase
|
||
{
|
||
public function testListShowsMenusAndLocations(): void
|
||
{
|
||
Functions\when('wp_get_nav_menus')->justReturn([
|
||
(object) ['term_id' => 3, 'name' => 'Hauptmenü', 'slug' => 'hauptmenue', 'count' => 4],
|
||
]);
|
||
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü', 'footer-menu' => 'Footer']);
|
||
Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]);
|
||
|
||
$result = (new MenuListAbility())->execute([]);
|
||
|
||
self::assertSame(['primary-menu'], $result['menus'][0]['locations']);
|
||
self::assertSame(
|
||
[
|
||
['location' => 'primary-menu', 'description' => 'Hauptmenü', 'menu_id' => 3],
|
||
['location' => 'footer-menu', 'description' => 'Footer', 'menu_id' => null],
|
||
],
|
||
$result['locations']
|
||
);
|
||
}
|
||
|
||
public function testGetReturnsTheTreeOrNotFound(): void
|
||
{
|
||
Functions\when('wp_get_nav_menu_object')->alias(
|
||
static fn (int $id) => $id === 3 ? (object) ['term_id' => 3, 'name' => 'Hauptmenü'] : false
|
||
);
|
||
Functions\when('wp_get_nav_menu_items')->justReturn([MenuTreeTest::item(10, 0, 'Start')]);
|
||
Functions\when('get_nav_menu_locations')->justReturn([]);
|
||
$ability = new MenuGetAbility();
|
||
|
||
$result = $ability->execute(['menu_id' => 3]);
|
||
|
||
self::assertSame('Hauptmenü', $result['name']);
|
||
self::assertSame('Start', $result['items'][0]['title']);
|
||
self::assertSame('lit_not_found', $ability->execute(['menu_id' => 9])->get_error_code());
|
||
}
|
||
|
||
public function testReadToolsNeedThemeOptionRights(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
|
||
self::assertSame('lit_forbidden', (new MenuListAbility())->permission([])->get_error_code());
|
||
self::assertSame('lit_forbidden', (new MenuGetAbility())->permission(['menu_id' => 3])->get_error_code());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Error;
|
||
|
||
final class MenuCreateAbilityTest extends TestCase
|
||
{
|
||
public function testUnknownLocationIsRejectedBeforeCreating(): void
|
||
{
|
||
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
|
||
Functions\expect('wp_create_nav_menu')->never();
|
||
|
||
$result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Neu', 'location' => 'nope']);
|
||
|
||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||
self::assertStringContainsString('primary-menu', $result->get_error_message());
|
||
}
|
||
|
||
public function testExistingNameIsAValidationError(): void
|
||
{
|
||
Functions\when('wp_create_nav_menu')->justReturn(new WP_Error('menu_exists', 'Der Menüname existiert bereits.'));
|
||
|
||
$result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Hauptmenü']);
|
||
|
||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||
}
|
||
|
||
public function testCreatesAndAssignsTheLocation(): void
|
||
{
|
||
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
|
||
Functions\expect('wp_create_nav_menu')->once()->with('Neu')->andReturn(8);
|
||
Functions\when('get_nav_menu_locations')->justReturn(['footer-menu' => 2]);
|
||
Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['footer-menu' => 2, 'primary-menu' => 8]);
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->with('lit/menu-create', 'nav_menu', '8', 'Menü "Neu" angelegt (Position primary-menu)')->andReturn(5);
|
||
|
||
$result = (new MenuCreateAbility($log))->execute(['name' => 'Neu', 'location' => 'primary-menu']);
|
||
|
||
self::assertSame(8, $result['menu_id']);
|
||
self::assertSame('created', $result['changed'][0]['action']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class MenuAssignLocationAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
|
||
Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]);
|
||
Functions\when('wp_get_nav_menu_object')->alias(static fn (int $id) => $id === 8 ? (object) ['term_id' => 8, 'name' => 'Neu'] : false);
|
||
}
|
||
|
||
public function testAssignsAndReportsThePreviousMenu(): void
|
||
{
|
||
Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['primary-menu' => 8]);
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->andReturn(6);
|
||
|
||
$result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 8]);
|
||
|
||
self::assertSame(3, $result['previous_menu_id']);
|
||
self::assertSame('assigned', $result['changed'][0]['action']);
|
||
}
|
||
|
||
public function testZeroRemovesTheAssignment(): void
|
||
{
|
||
Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', []);
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->andReturn(7);
|
||
|
||
$result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 0]);
|
||
|
||
self::assertSame('unassigned', $result['changed'][0]['action']);
|
||
}
|
||
|
||
public function testUnknownMenuIsNotFound(): void
|
||
{
|
||
Functions\expect('set_theme_mod')->never();
|
||
|
||
$result = (new MenuAssignLocationAbility(Mockery::mock(ChangeLog::class)))
|
||
->execute(['location' => 'primary-menu', 'menu_id' => 99]);
|
||
|
||
self::assertSame('lit_not_found', $result->get_error_code());
|
||
}
|
||
}
|
||
```
|
||
|
||
In `WordPressIntegrationTest` die Konstante ersetzen (Reihenfolge wie in `abilities()`):
|
||
```php
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
'lit/content-query',
|
||
'lit/content-get',
|
||
'lit/content-create',
|
||
'lit/content-update',
|
||
'lit/post-publish',
|
||
'lit/post-unpublish',
|
||
'lit/post-schedule',
|
||
'lit/post-trash',
|
||
'lit/post-duplicate',
|
||
'lit/post-replace-with-copy',
|
||
'lit/revisions-list',
|
||
'lit/revision-restore',
|
||
'lit/menu-list',
|
||
'lit/menu-get',
|
||
'lit/menu-create',
|
||
'lit/menu-assign-location',
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'Menu|WordPressIntegrationTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Menus/MenuTree.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
/**
|
||
* Turns the flat wp_get_nav_menu_items() list into a nested tree.
|
||
*/
|
||
final class MenuTree
|
||
{
|
||
/**
|
||
* @param list<object> $items Sorted by menu_order (wp_get_nav_menu_items default).
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
public static function build(array $items): array
|
||
{
|
||
$nodes = [];
|
||
foreach ($items as $item) {
|
||
$nodes[(int) $item->ID] = self::node($item);
|
||
}
|
||
|
||
$children = [];
|
||
foreach ($items as $item) {
|
||
$id = (int) $item->ID;
|
||
$parent = (int) $item->menu_item_parent;
|
||
$children[$parent !== $id && isset($nodes[$parent]) ? $parent : 0][] = $id;
|
||
}
|
||
|
||
return self::attach(0, $nodes, $children);
|
||
}
|
||
|
||
/** @return array{id: int, title: string, type: string, object: string, object_id: int, url: string} */
|
||
public static function node(object $item): array
|
||
{
|
||
return [
|
||
'id' => (int) $item->ID,
|
||
'title' => (string) $item->title,
|
||
'type' => match ((string) $item->type) {
|
||
'post_type' => 'post',
|
||
'taxonomy' => 'taxonomy',
|
||
default => 'custom',
|
||
},
|
||
'object' => (string) $item->object,
|
||
'object_id' => (int) $item->object_id,
|
||
'url' => (string) $item->url,
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> JSON schema of one tree node (children not validated deeper). */
|
||
public static function nodeSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'integer'],
|
||
'title' => ['type' => 'string'],
|
||
'type' => ['type' => 'string'],
|
||
'object' => ['type' => 'string'],
|
||
'object_id' => ['type' => 'integer'],
|
||
'url' => ['type' => 'string'],
|
||
'children' => ['type' => 'array'],
|
||
],
|
||
'required' => ['id', 'title', 'type', 'children'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array<string, mixed>> $nodes
|
||
* @param array<int, list<int>> $children
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private static function attach(int $parent, array $nodes, array $children): array
|
||
{
|
||
$result = [];
|
||
foreach ($children[$parent] ?? [] as $id) {
|
||
$result[] = array_merge($nodes[$id], ['children' => self::attach($id, $nodes, $children)]);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Menus/MenuLocations.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
/**
|
||
* Theme menu locations (theme mod nav_menu_locations).
|
||
*/
|
||
final class MenuLocations
|
||
{
|
||
/** @return array<string, string> Location slug => description. */
|
||
public static function registered(): array
|
||
{
|
||
return array_map('strval', (array) get_registered_nav_menus());
|
||
}
|
||
|
||
/** @return array<string, int> Location slug => menu ID. */
|
||
public static function assigned(): array
|
||
{
|
||
$locations = get_nav_menu_locations();
|
||
|
||
return is_array($locations) ? array_map('intval', $locations) : [];
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public static function of(int $menuId): array
|
||
{
|
||
return array_map('strval', array_keys(array_filter(
|
||
self::assigned(),
|
||
static fn (int $id): bool => $id === $menuId
|
||
)));
|
||
}
|
||
|
||
public static function assign(string $location, int $menuId): void
|
||
{
|
||
$locations = self::assigned();
|
||
if ($menuId > 0) {
|
||
$locations[$location] = $menuId;
|
||
} else {
|
||
unset($locations[$location]);
|
||
}
|
||
|
||
set_theme_mod('nav_menu_locations', $locations);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Menus/MenuListAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
final class MenuListAbility implements Ability
|
||
{
|
||
public function name(): string
|
||
{
|
||
return 'lit/menu-list';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Menüs auflisten';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Listet alle Menüs mit Anzahl der Einträge sowie die Menü-Positionen des Themes und welches '
|
||
. 'Menü dort zugewiesen ist.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return ['type' => 'object', 'properties' => [], 'additionalProperties' => false];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'menus' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'locations' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
],
|
||
'required' => ['menus', 'locations'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return current_user_can('edit_theme_options')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$menus = [];
|
||
foreach ((array) wp_get_nav_menus() as $menu) {
|
||
$menus[] = [
|
||
'id' => (int) $menu->term_id,
|
||
'name' => (string) $menu->name,
|
||
'slug' => (string) $menu->slug,
|
||
'count' => (int) $menu->count,
|
||
'locations' => MenuLocations::of((int) $menu->term_id),
|
||
];
|
||
}
|
||
|
||
$assigned = MenuLocations::assigned();
|
||
$locations = [];
|
||
foreach (MenuLocations::registered() as $location => $description) {
|
||
$menuId = $assigned[$location] ?? 0;
|
||
$locations[] = [
|
||
'location' => (string) $location,
|
||
'description' => $description,
|
||
'menu_id' => $menuId > 0 ? $menuId : null,
|
||
];
|
||
}
|
||
|
||
return ['menus' => $menus, 'locations' => $locations];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Menus/MenuGetAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
final class MenuGetAbility implements Ability
|
||
{
|
||
public function name(): string
|
||
{
|
||
return 'lit/menu-get';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Menü lesen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Liefert ein Menü als Baum (id, title, type post/taxonomy/custom, object_id, url, children). '
|
||
. 'Der Baum kann verändert an lit-menu-set-items zurückgegeben werden.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => ['menu_id' => ['type' => 'integer', 'minimum' => 1]],
|
||
'required' => ['menu_id'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'integer'],
|
||
'name' => ['type' => 'string'],
|
||
'locations' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
'items' => ['type' => 'array', 'items' => MenuTree::nodeSchema()],
|
||
],
|
||
'required' => ['id', 'name', 'locations', 'items'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return current_user_can('edit_theme_options')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$menuId = (int) ($input['menu_id'] ?? 0);
|
||
$menu = wp_get_nav_menu_object($menuId);
|
||
if (! is_object($menu)) {
|
||
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
|
||
}
|
||
|
||
$items = wp_get_nav_menu_items($menuId);
|
||
|
||
return [
|
||
'id' => (int) $menu->term_id,
|
||
'name' => (string) $menu->name,
|
||
'locations' => MenuLocations::of((int) $menu->term_id),
|
||
'items' => MenuTree::build(is_array($items) ? $items : []),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Menus/MenuCreateAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use WP_Error;
|
||
|
||
final class MenuCreateAbility implements Ability
|
||
{
|
||
public function __construct(private ChangeLog $log)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/menu-create';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Menü anlegen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Legt ein leeres Menü an und weist es optional einer Theme-Position zu. Einträge danach mit '
|
||
. 'lit-menu-set-items setzen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'name' => ['type' => 'string', 'minLength' => 1],
|
||
'location' => ['type' => 'string', 'description' => 'Theme-Position aus lit-menu-list.'],
|
||
],
|
||
'required' => ['name'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema(['menu_id' => ['type' => 'integer']], ['menu_id']);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return current_user_can('edit_theme_options')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$name = trim((string) ($input['name'] ?? ''));
|
||
$location = isset($input['location']) ? (string) $input['location'] : '';
|
||
if ($location !== '' && ! array_key_exists($location, MenuLocations::registered())) {
|
||
return Errors::invalidInput(
|
||
'Unbekannte Menü-Position.',
|
||
['location' => 'verfügbar: ' . implode(', ', array_keys(MenuLocations::registered()))]
|
||
);
|
||
}
|
||
|
||
$menuId = wp_create_nav_menu($name);
|
||
if ($menuId instanceof WP_Error) {
|
||
return Errors::invalidInput(
|
||
'Menü konnte nicht angelegt werden: ' . $menuId->get_error_message(),
|
||
['name' => 'bereits vergeben oder ungültig'],
|
||
'Vorhandene Menüs über lit-menu-list abrufen.'
|
||
);
|
||
}
|
||
|
||
$menuId = (int) $menuId;
|
||
if ($location !== '') {
|
||
MenuLocations::assign($location, $menuId);
|
||
}
|
||
|
||
$summary = sprintf('Menü "%s" angelegt', $name)
|
||
. ($location !== '' ? sprintf(' (Position %s)', $location) : '');
|
||
$logId = $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary);
|
||
|
||
return ToolResult::ok($summary . sprintf(', ID %d.', $menuId))
|
||
->changed((string) $menuId, 'nav_menu', 'created', $name)
|
||
->logId($logId)
|
||
->with('menu_id', $menuId)
|
||
->toArray();
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use WP_Error;
|
||
|
||
final class MenuAssignLocationAbility implements Ability
|
||
{
|
||
public function __construct(private ChangeLog $log)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/menu-assign-location';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Menü-Position zuweisen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Weist einer Theme-Position (siehe lit-menu-list) ein Menü zu; menu_id 0 entfernt die Zuweisung. '
|
||
. 'Das vorher zugewiesene Menü steht in previous_menu_id.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'location' => ['type' => 'string', 'minLength' => 1],
|
||
'menu_id' => ['type' => 'integer', 'minimum' => 0],
|
||
],
|
||
'required' => ['location', 'menu_id'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema(['previous_menu_id' => ['type' => ['integer', 'null']]], ['previous_menu_id']);
|
||
}
|
||
|
||
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
|
||
{
|
||
return current_user_can('edit_theme_options')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$location = (string) ($input['location'] ?? '');
|
||
$menuId = (int) ($input['menu_id'] ?? 0);
|
||
$registered = MenuLocations::registered();
|
||
if (! array_key_exists($location, $registered)) {
|
||
return Errors::invalidInput(
|
||
'Unbekannte Menü-Position.',
|
||
['location' => 'verfügbar: ' . implode(', ', array_keys($registered))]
|
||
);
|
||
}
|
||
|
||
if ($menuId > 0 && ! is_object(wp_get_nav_menu_object($menuId))) {
|
||
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
|
||
}
|
||
|
||
$previous = MenuLocations::assigned()[$location] ?? 0;
|
||
MenuLocations::assign($location, $menuId);
|
||
|
||
$action = $menuId > 0 ? 'assigned' : 'unassigned';
|
||
$summary = $menuId > 0
|
||
? sprintf('Menü %d der Position %s zugewiesen', $menuId, $location)
|
||
: sprintf('Zuweisung der Position %s entfernt', $location);
|
||
$logId = $this->log->record($this->name(), 'menu_location', $location, $summary);
|
||
|
||
return ToolResult::ok($summary . '.')
|
||
->changed($location, 'menu_location', $action, $registered[$location])
|
||
->logId($logId)
|
||
->with('previous_menu_id', $previous > 0 ? $previous : null)
|
||
->toArray();
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen
|
||
```php
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
|
||
```
|
||
und in `abilities()` nach `new RevisionRestoreAbility(...)` einfügen:
|
||
```php
|
||
new MenuListAbility(),
|
||
new MenuGetAbility(),
|
||
new MenuCreateAbility($this->log),
|
||
new MenuAssignLocationAbility($this->log),
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: menu list, get, create and location tools
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 14: Menüeinträge setzen – `MenuOperations`, `MenuWriter`, `lit/menu-set-items`
|
||
|
||
Spec §6.4: Einträge deklarativ als Gesamtbaum **oder** als Einzeloperationen (hinzufügen/verschieben/entfernen). Beide Wege laufen durch denselben Schreibpfad: Operationen werden auf den aktuellen Baum angewendet und ergeben einen Zielbaum. Jede Entfernung braucht `confirm: true`; die Antwort enthält immer `previous_items` (Baum vorher), damit Claude einen Fehler zurückbauen kann. Menüeinträge (`nav_menu_item`) löscht WordPress selbst ohne Papierkorb (`wp_delete_post($id, true)`, wie der Menü-Editor) – das ist die einzige Ausnahme von „nie endgültig löschen“, sie betrifft nur Menüstruktur, keine Inhalte.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Menus/MenuOperations.php`, `src/Integrations/WordPress/Menus/MenuWriter.php`, `src/Integrations/WordPress/Menus/MenuSetItemsAbility.php`
|
||
- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MenuTree::build()` (Task 13), `ChangeLog`, `ToolResult`, `Errors`, Test-Helfer `MenuTreeTest::item()`.
|
||
- Produces:
|
||
- Zielbaum-Knoten (Eingabe): bestehender Eintrag `{id, title?, url? (nur custom), children?}`; neuer Eintrag `{type: 'post'|'taxonomy'|'custom', object_id? (post/taxonomy), url? (custom), title? (custom: Pflicht), children?}`.
|
||
- `MenuOperations` (final): `apply(list<array> $tree, list<array> $operations): list<array>|WP_Error` – Operationen `{op: 'add', item: {…}, parent_id?, position?}`, `{op: 'move', id, parent_id?, position?}`, `{op: 'remove', id}` (Kinder rücken an die Stelle des entfernten Eintrags); `position` = 0-basierter Index unter den Geschwistern, ohne = ans Ende.
|
||
- `MenuWriter` (nicht final): `plan(list<mixed> $tree, array<int, object> $current): array{errors: array<string, string>, removals: list<array{id: int, title: string}>, warnings: list<string>}` (schreibt nichts); `apply(int $menuId, list<array> $tree, array<int, object> $current): array{created: int, updated: int, removed: int}` (wirft `RuntimeException`).
|
||
- `MenuSetItemsAbility`: `__construct(MenuWriter $writer, MenuOperations $operations, ChangeLog $log)`; `lit/menu-set-items`, Input `{menu_id, items? | operations?, confirm?}`; Ergebnis-Extras `items` (Baum nachher), `previous_items` (Baum vorher); indirekt; `destructive: true`, nicht idempotent.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class MenuOperationsTest extends TestCase
|
||
{
|
||
/** @return list<array<string, mixed>> Start(1) > Team(2); Kontakt(3) */
|
||
private function tree(): array
|
||
{
|
||
return [
|
||
['id' => 1, 'title' => 'Start', 'children' => [['id' => 2, 'title' => 'Team', 'children' => []]]],
|
||
['id' => 3, 'title' => 'Kontakt', 'children' => []],
|
||
];
|
||
}
|
||
|
||
public function testAddInsertsANewNodeAtThePosition(): void
|
||
{
|
||
$result = (new MenuOperations())->apply($this->tree(), [
|
||
['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog'], 'position' => 1],
|
||
]);
|
||
|
||
self::assertSame(1, $result[0]['id']);
|
||
self::assertSame(['type' => 'custom', 'url' => '/blog', 'title' => 'Blog', 'children' => []], $result[1]);
|
||
self::assertSame(3, $result[2]['id']);
|
||
}
|
||
|
||
public function testMoveAndRemoveKeepChildren(): void
|
||
{
|
||
$result = (new MenuOperations())->apply($this->tree(), [
|
||
['op' => 'move', 'id' => 3, 'parent_id' => 1, 'position' => 0],
|
||
['op' => 'remove', 'id' => 1],
|
||
]);
|
||
|
||
self::assertSame([3, 2], array_column($result, 'id'));
|
||
self::assertSame([], $result[0]['children']);
|
||
}
|
||
|
||
public function testInvalidOperationsAreReportedWithTheirIndex(): void
|
||
{
|
||
$operations = new MenuOperations();
|
||
|
||
$unknown = $operations->apply($this->tree(), [['op' => 'move', 'id' => 99]]);
|
||
$cycle = $operations->apply($this->tree(), [['op' => 'move', 'id' => 1, 'parent_id' => 2]]);
|
||
$badOp = $operations->apply($this->tree(), [['op' => 'rename', 'id' => 1]]);
|
||
|
||
self::assertArrayHasKey('operations.0', $unknown->get_error_data()['fields']);
|
||
self::assertStringContainsString('unter sich selbst', $cycle->get_error_data()['fields']['operations.0']);
|
||
self::assertStringContainsString('add, move oder remove', $badOp->get_error_data()['fields']['operations.0']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class MenuWriterTest extends TestCase
|
||
{
|
||
/** @return array<int, object> */
|
||
private function current(): array
|
||
{
|
||
return [
|
||
10 => MenuTreeTest::item(10, 0, 'Home', 'custom', '/'),
|
||
11 => MenuTreeTest::item(11, 0, 'Über uns'),
|
||
];
|
||
}
|
||
|
||
public function testPlanFindsErrorsRemovalsAndWarnings(): void
|
||
{
|
||
Functions\when('wp_parse_url')->alias('parse_url');
|
||
Functions\when('get_post')->alias(static fn (int $id) => $id === 7
|
||
? new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Neu'])
|
||
: null);
|
||
|
||
$plan = (new MenuWriter())->plan([
|
||
['id' => 11, 'children' => [['id' => 11]]],
|
||
['id' => 55],
|
||
['type' => 'post', 'object_id' => 7],
|
||
['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X'],
|
||
['type' => 'custom', 'url' => '/kontakt'],
|
||
], $this->current());
|
||
|
||
self::assertStringContainsString('mehrfach', $plan['errors']['items.0.children.0']);
|
||
self::assertStringContainsString('kein Eintrag dieses Menüs', $plan['errors']['items.1']);
|
||
self::assertArrayNotHasKey('items.2', $plan['errors']);
|
||
self::assertStringContainsString('url', $plan['errors']['items.3']);
|
||
self::assertStringContainsString('title', $plan['errors']['items.4']);
|
||
self::assertSame([['id' => 10, 'title' => 'Home']], $plan['removals']);
|
||
self::assertStringContainsString('"Neu" ist nicht veröffentlicht', $plan['warnings'][0]);
|
||
}
|
||
|
||
public function testApplyWritesInTreeOrderKeepsExistingDataAndRemovesTheRest(): void
|
||
{
|
||
Functions\when('esc_url_raw')->returnArg();
|
||
Functions\expect('wp_update_nav_menu_item')->once()->with(3, 11, Mockery::on(static fn (array $args): bool =>
|
||
$args['menu-item-title'] === 'Wir'
|
||
&& $args['menu-item-type'] === 'post_type'
|
||
&& $args['menu-item-object-id'] === 111
|
||
&& $args['menu-item-parent-id'] === 0
|
||
&& $args['menu-item-position'] === 1
|
||
&& $args['menu-item-status'] === 'publish'))->andReturn(11);
|
||
Functions\expect('wp_update_nav_menu_item')->once()->with(3, 0, Mockery::on(static fn (array $args): bool =>
|
||
$args['menu-item-type'] === 'custom'
|
||
&& $args['menu-item-url'] === '/kontakt'
|
||
&& $args['menu-item-title'] === 'Kontakt'
|
||
&& $args['menu-item-parent-id'] === 11
|
||
&& $args['menu-item-position'] === 2))->andReturn(12);
|
||
Functions\expect('wp_delete_post')->once()->with(10, true);
|
||
|
||
$stats = (new MenuWriter())->apply(3, [
|
||
['id' => 11, 'title' => 'Wir', 'children' => [['type' => 'custom', 'url' => '/kontakt', 'title' => 'Kontakt']]],
|
||
], $this->current());
|
||
|
||
self::assertSame(['created' => 1, 'updated' => 1, 'removed' => 1], $stats);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class MenuSetItemsAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('wp_get_nav_menu_object')->justReturn((object) ['term_id' => 3, 'name' => 'Hauptmenü']);
|
||
Functions\when('wp_get_nav_menu_items')->justReturn([MenuTreeTest::item(10, 0, 'Home', 'custom', '/')]);
|
||
}
|
||
|
||
public function testItemsAndOperationsAreMutuallyExclusive(): void
|
||
{
|
||
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
|
||
|
||
self::assertSame('lit_validation_failed', $ability->execute(['menu_id' => 3])->get_error_code());
|
||
self::assertSame(
|
||
'lit_validation_failed',
|
||
$ability->execute(['menu_id' => 3, 'items' => [], 'operations' => [['op' => 'remove', 'id' => 10]]])->get_error_code()
|
||
);
|
||
}
|
||
|
||
public function testRemovalsNeedConfirmationAndNothingIsWritten(): void
|
||
{
|
||
Functions\expect('wp_update_nav_menu_item')->never();
|
||
Functions\expect('wp_delete_post')->never();
|
||
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
|
||
|
||
$result = $ability->execute(['menu_id' => 3, 'items' => []]);
|
||
|
||
self::assertSame('lit_confirm_required', $result->get_error_code());
|
||
self::assertSame([['id' => 10, 'title' => 'Home']], $result->get_error_data()['removals']);
|
||
}
|
||
|
||
public function testAppliesAndReturnsTheTreeBeforeAndAfter(): void
|
||
{
|
||
$writer = Mockery::mock(MenuWriter::class);
|
||
$writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]);
|
||
$writer->expects('apply')->andReturn(['created' => 1, 'updated' => 1, 'removed' => 0]);
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->with('lit/menu-set-items', 'nav_menu', '3', Mockery::type('string'))->andReturn(21);
|
||
|
||
$result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute([
|
||
'menu_id' => 3,
|
||
'operations' => [['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog']]],
|
||
]);
|
||
|
||
self::assertSame('Home', $result['previous_items'][0]['title']);
|
||
self::assertSame(21, $result['log_id']);
|
||
self::assertStringContainsString('1 neu, 1 geändert, 0 entfernt', $result['summary']);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `WordPressIntegrationTest` die Konstante um `'lit/menu-set-items'` nach `'lit/menu-create'` ergänzen:
|
||
```php
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
'lit/content-query',
|
||
'lit/content-get',
|
||
'lit/content-create',
|
||
'lit/content-update',
|
||
'lit/post-publish',
|
||
'lit/post-unpublish',
|
||
'lit/post-schedule',
|
||
'lit/post-trash',
|
||
'lit/post-duplicate',
|
||
'lit/post-replace-with-copy',
|
||
'lit/revisions-list',
|
||
'lit/revision-restore',
|
||
'lit/menu-list',
|
||
'lit/menu-get',
|
||
'lit/menu-create',
|
||
'lit/menu-set-items',
|
||
'lit/menu-assign-location',
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'MenuOperations|MenuWriter|MenuSetItems|WordPressIntegrationTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Menus/MenuOperations.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Applies add/move/remove operations to the current menu tree and returns the
|
||
* desired tree for MenuWriter – so single operations and the declarative mode
|
||
* share one validated write path.
|
||
*/
|
||
final class MenuOperations
|
||
{
|
||
private const ROOT = 'root';
|
||
|
||
/**
|
||
* @param list<array<string, mixed>> $tree Current tree (MenuTree::build()).
|
||
* @param list<mixed> $operations
|
||
* @return list<array<string, mixed>>|WP_Error
|
||
*/
|
||
public function apply(array $tree, array $operations): array|WP_Error
|
||
{
|
||
$nodes = [];
|
||
$children = [self::ROOT => []];
|
||
$this->flatten($tree, self::ROOT, $nodes, $children);
|
||
|
||
foreach ($operations as $index => $operation) {
|
||
$error = $this->applyOne($operation, (int) $index, $nodes, $children);
|
||
if ($error !== null) {
|
||
return Errors::invalidInput(
|
||
'Menü-Operation ungültig; nichts wurde geändert.',
|
||
['operations.' . $index => $error]
|
||
);
|
||
}
|
||
}
|
||
|
||
return $this->nest(self::ROOT, $nodes, $children);
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string, mixed>> $tree
|
||
* @param array<string, array<string, mixed>> $nodes
|
||
* @param array<string, list<string>> $children
|
||
*/
|
||
private function flatten(array $tree, string $parent, array &$nodes, array &$children): void
|
||
{
|
||
foreach ($tree as $item) {
|
||
$key = 'id:' . (int) $item['id'];
|
||
$nodes[$key] = ['id' => (int) $item['id']];
|
||
$children[$parent][] = $key;
|
||
$children[$key] = [];
|
||
$this->flatten((array) ($item['children'] ?? []), $key, $nodes, $children);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array<string, mixed>> $nodes
|
||
* @param array<string, list<string>> $children
|
||
*/
|
||
private function applyOne(mixed $operation, int $index, array &$nodes, array &$children): ?string
|
||
{
|
||
if (! is_array($operation)) {
|
||
return 'Objekt erwartet.';
|
||
}
|
||
|
||
$parentId = (int) ($operation['parent_id'] ?? 0);
|
||
$parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT;
|
||
if (! isset($children[$parentKey])) {
|
||
return 'parent_id ist kein Eintrag dieses Menüs.';
|
||
}
|
||
|
||
$position = isset($operation['position']) ? (int) $operation['position'] : null;
|
||
$key = 'id:' . (int) ($operation['id'] ?? 0);
|
||
|
||
switch ($operation['op'] ?? '') {
|
||
case 'add':
|
||
if (! is_array($operation['item'] ?? null)) {
|
||
return 'item fehlt.';
|
||
}
|
||
$newKey = 'new:' . $index;
|
||
$nodes[$newKey] = array_diff_key($operation['item'], ['id' => true, 'children' => true]);
|
||
$children[$newKey] = [];
|
||
$this->insert($children[$parentKey], $newKey, $position);
|
||
return null;
|
||
case 'move':
|
||
if (! isset($nodes[$key])) {
|
||
return 'id ist kein Eintrag dieses Menüs.';
|
||
}
|
||
if ($key === $parentKey || $this->isDescendant($parentKey, $key, $children)) {
|
||
return 'Ein Eintrag kann nicht unter sich selbst verschoben werden.';
|
||
}
|
||
$this->detach($key, $children);
|
||
$this->insert($children[$parentKey], $key, $position);
|
||
return null;
|
||
case 'remove':
|
||
if (! isset($nodes[$key])) {
|
||
return 'id ist kein Eintrag dieses Menüs.';
|
||
}
|
||
[$owner, $at] = $this->detach($key, $children);
|
||
array_splice($children[$owner], $at, 0, $children[$key]);
|
||
unset($nodes[$key], $children[$key]);
|
||
return null;
|
||
default:
|
||
return 'op muss add, move oder remove sein.';
|
||
}
|
||
}
|
||
|
||
/** @param list<string> $list */
|
||
private function insert(array &$list, string $key, ?int $position): void
|
||
{
|
||
if ($position === null || $position >= count($list)) {
|
||
$list[] = $key;
|
||
return;
|
||
}
|
||
|
||
array_splice($list, max(0, $position), 0, [$key]);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, list<string>> $children
|
||
* @return array{0: string, 1: int} Former parent key and index.
|
||
*/
|
||
private function detach(string $key, array &$children): array
|
||
{
|
||
foreach ($children as $parent => $list) {
|
||
$index = array_search($key, $list, true);
|
||
if ($index !== false) {
|
||
array_splice($children[$parent], (int) $index, 1);
|
||
return [(string) $parent, (int) $index];
|
||
}
|
||
}
|
||
|
||
return [self::ROOT, count($children[self::ROOT])];
|
||
}
|
||
|
||
/** @param array<string, list<string>> $children */
|
||
private function isDescendant(string $candidate, string $ancestor, array $children): bool
|
||
{
|
||
foreach ($children[$ancestor] ?? [] as $child) {
|
||
if ($child === $candidate || $this->isDescendant($candidate, $child, $children)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array<string, mixed>> $nodes
|
||
* @param array<string, list<string>> $children
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private function nest(string $parent, array $nodes, array $children): array
|
||
{
|
||
$result = [];
|
||
foreach ($children[$parent] ?? [] as $key) {
|
||
$result[] = array_merge($nodes[$key], ['children' => $this->nest($key, $nodes, $children)]);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Menus/MenuWriter.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
use RuntimeException;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
/**
|
||
* Validates a desired menu tree against the current items and writes it.
|
||
* Existing items keep all their data (classes, target, description, …) unless
|
||
* the node overrides title/url. Not final so the ability can be tested with a mock.
|
||
*/
|
||
class MenuWriter
|
||
{
|
||
private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment'];
|
||
private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
|
||
|
||
/**
|
||
* @param list<mixed> $tree
|
||
* @param array<int, object> $current Current items by ID.
|
||
* @return array{errors: array<string, string>, removals: list<array{id: int, title: string}>, warnings: list}
|
||
*/
|
||
public function plan(array $tree, array $current): array
|
||
{
|
||
$errors = [];
|
||
$warnings = [];
|
||
$seen = [];
|
||
$this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings);
|
||
|
||
$removals = [];
|
||
foreach ($current as $id => $item) {
|
||
if (! isset($seen[$id])) {
|
||
$removals[] = ['id' => (int) $id, 'title' => (string) $item->title];
|
||
}
|
||
}
|
||
|
||
return ['errors' => $errors, 'removals' => $removals, 'warnings' => $warnings];
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string, mixed>> $tree Validated by plan().
|
||
* @param array<int, object> $current
|
||
* @return array{created: int, updated: int, removed: int}
|
||
* @throws RuntimeException
|
||
*/
|
||
public function apply(int $menuId, array $tree, array $current): array
|
||
{
|
||
$stats = ['created' => 0, 'updated' => 0, 'removed' => 0];
|
||
$position = 0;
|
||
$seen = [];
|
||
$this->writeNodes($menuId, $tree, 0, $current, $position, $seen, $stats);
|
||
|
||
foreach (array_keys($current) as $id) {
|
||
if (! isset($seen[$id])) {
|
||
// Menu items have no trash in WordPress; the core menu editor deletes them the same way.
|
||
wp_delete_post((int) $id, true);
|
||
$stats['removed']++;
|
||
}
|
||
}
|
||
|
||
return $stats;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, object> $current
|
||
* @param array<int, true> $seen
|
||
* @param array<string, string> $errors
|
||
* @param list<string> $warnings
|
||
*/
|
||
private function validateNodes(
|
||
mixed $nodes,
|
||
string $path,
|
||
array $current,
|
||
array &$seen,
|
||
array &$errors,
|
||
array &$warnings
|
||
): void {
|
||
if (! is_array($nodes) || ! array_is_list($nodes)) {
|
||
$errors[$path] = 'Liste erwartet.';
|
||
return;
|
||
}
|
||
|
||
foreach ($nodes as $index => $node) {
|
||
$nodePath = $path . '.' . $index;
|
||
if (! is_array($node)) {
|
||
$errors[$nodePath] = 'Objekt erwartet.';
|
||
continue;
|
||
}
|
||
|
||
$error = array_key_exists('id', $node)
|
||
? $this->checkExisting($node, $current, $seen)
|
||
: $this->checkNew($node, $warnings);
|
||
if ($error !== null) {
|
||
$errors[$nodePath] = $error;
|
||
}
|
||
|
||
if (array_key_exists('children', $node)) {
|
||
$this->validateNodes($node['children'], $nodePath . '.children', $current, $seen, $errors, $warnings);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $node
|
||
* @param array<int, object> $current
|
||
* @param array<int, true> $seen
|
||
*/
|
||
private function checkExisting(array $node, array $current, array &$seen): ?string
|
||
{
|
||
$id = $node['id'];
|
||
if (! is_int($id) || ! isset($current[$id])) {
|
||
return 'id ist kein Eintrag dieses Menüs (für neue Einträge id weglassen).';
|
||
}
|
||
|
||
if (isset($seen[$id])) {
|
||
return sprintf('Eintrag %d kommt mehrfach vor.', $id);
|
||
}
|
||
$seen[$id] = true;
|
||
|
||
if (array_key_exists('title', $node) && ! is_string($node['title'])) {
|
||
return 'title muss Text sein.';
|
||
}
|
||
|
||
if (array_key_exists('url', $node)) {
|
||
if ((string) $current[$id]->type !== 'custom') {
|
||
return 'url ist nur bei individuellen Links änderbar.';
|
||
}
|
||
if (! $this->validUrl($node['url'])) {
|
||
return 'Ungültige url (erlaubt: /pfad, #anker, http(s), mailto, tel).';
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $node
|
||
* @param list<string> $warnings
|
||
*/
|
||
private function checkNew(array $node, array &$warnings): ?string
|
||
{
|
||
$objectId = $node['object_id'] ?? null;
|
||
|
||
switch ($node['type'] ?? null) {
|
||
case 'post':
|
||
$post = is_int($objectId) ? get_post($objectId) : null;
|
||
if (! $post instanceof WP_Post || in_array($post->post_type, self::NON_LINKABLE, true)) {
|
||
return 'object_id muss ein vorhandener Eintrag sein.';
|
||
}
|
||
if ($post->post_status !== 'publish') {
|
||
$warnings[] = sprintf(
|
||
'"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.',
|
||
$post->post_title
|
||
);
|
||
}
|
||
return null;
|
||
case 'taxonomy':
|
||
return is_int($objectId) && get_term($objectId) instanceof WP_Term
|
||
? null
|
||
: 'object_id muss ein vorhandener Begriff sein.';
|
||
case 'custom':
|
||
if (! $this->validUrl($node['url'] ?? null)) {
|
||
return 'Individueller Link braucht eine gültige url (/pfad, #anker, http(s), mailto, tel).';
|
||
}
|
||
return is_string($node['title'] ?? null) && trim($node['title']) !== ''
|
||
? null
|
||
: 'Individueller Link braucht einen title.';
|
||
default:
|
||
return 'type muss post, taxonomy oder custom sein.';
|
||
}
|
||
}
|
||
|
||
private function validUrl(mixed $url): bool
|
||
{
|
||
if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) {
|
||
return false;
|
||
}
|
||
|
||
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
|
||
return true;
|
||
}
|
||
|
||
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
|
||
|
||
return is_string($scheme) && in_array(strtolower($scheme), self::URL_SCHEMES, true);
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string, mixed>> $nodes
|
||
* @param array<int, object> $current
|
||
* @param array<int, true> $seen
|
||
* @param array{created: int, updated: int, removed: int} $stats
|
||
*/
|
||
private function writeNodes(
|
||
int $menuId,
|
||
array $nodes,
|
||
int $parentId,
|
||
array $current,
|
||
int &$position,
|
||
array &$seen,
|
||
array &$stats
|
||
): void {
|
||
foreach ($nodes as $node) {
|
||
$position++;
|
||
$existing = isset($node['id']) ? $current[(int) $node['id']] : null;
|
||
$args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node);
|
||
if (isset($node['title'])) {
|
||
$args['menu-item-title'] = (string) $node['title'];
|
||
}
|
||
if (isset($node['url'])) {
|
||
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
|
||
}
|
||
$args['menu-item-parent-id'] = $parentId;
|
||
$args['menu-item-position'] = $position;
|
||
$args['menu-item-status'] = 'publish';
|
||
|
||
$itemId = wp_update_nav_menu_item($menuId, $existing !== null ? (int) $existing->ID : 0, $args);
|
||
if (is_wp_error($itemId)) {
|
||
throw new RuntimeException('Menu item could not be saved: ' . $itemId->get_error_message());
|
||
}
|
||
|
||
if ($existing !== null) {
|
||
$seen[(int) $existing->ID] = true;
|
||
$stats['updated']++;
|
||
} else {
|
||
$stats['created']++;
|
||
}
|
||
|
||
$children = (array) ($node['children'] ?? []);
|
||
$this->writeNodes($menuId, $children, (int) $itemId, $current, $position, $seen, $stats);
|
||
}
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function argsFromItem(object $item): array
|
||
{
|
||
$isCustom = (string) $item->type === 'custom';
|
||
|
||
return [
|
||
'menu-item-object-id' => (int) $item->object_id,
|
||
'menu-item-object' => (string) $item->object,
|
||
'menu-item-type' => (string) $item->type,
|
||
'menu-item-title' => (string) $item->post_title,
|
||
'menu-item-url' => $isCustom ? (string) $item->url : '',
|
||
'menu-item-description' => (string) $item->description,
|
||
'menu-item-attr-title' => (string) $item->attr_title,
|
||
'menu-item-target' => (string) $item->target,
|
||
'menu-item-classes' => implode(' ', array_filter((array) $item->classes)),
|
||
'menu-item-xfn' => (string) $item->xfn,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $node
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function argsFromNode(array $node): array
|
||
{
|
||
$objectId = (int) ($node['object_id'] ?? 0);
|
||
|
||
return match ($node['type']) {
|
||
'post' => [
|
||
'menu-item-type' => 'post_type',
|
||
'menu-item-object' => (string) get_post_type($objectId),
|
||
'menu-item-object-id' => $objectId,
|
||
],
|
||
'taxonomy' => [
|
||
'menu-item-type' => 'taxonomy',
|
||
'menu-item-object' => (string) get_term($objectId)->taxonomy,
|
||
'menu-item-object-id' => $objectId,
|
||
],
|
||
default => ['menu-item-type' => 'custom', 'menu-item-url' => ''],
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Menus/MenuSetItemsAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use WP_Error;
|
||
|
||
final class MenuSetItemsAbility implements Ability
|
||
{
|
||
public function __construct(
|
||
private MenuWriter $writer,
|
||
private MenuOperations $operations,
|
||
private ChangeLog $log
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/menu-set-items';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Menüeinträge setzen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Setzt die Einträge eines Menüs. Entweder items = vollständiger Zielbaum (bestehende Einträge mit '
|
||
. 'id aus lit-menu-get, neue mit type post|taxonomy|custom und object_id bzw. url+title, jeweils '
|
||
. 'optional children) oder operations = Liste aus {op: add, item, parent_id?, position?}, '
|
||
. '{op: move, id, parent_id?, position?}, {op: remove, id}. Einträge, die wegfallen, werden nur mit '
|
||
. 'confirm: true entfernt. Die Antwort enthält previous_items zum Zurückbauen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'menu_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'items' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'operations' => [
|
||
'type' => 'array',
|
||
'minItems' => 1,
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'op' => ['type' => 'string', 'enum' => ['add', 'move', 'remove']],
|
||
'id' => ['type' => 'integer', 'minimum' => 1],
|
||
'item' => ['type' => 'object'],
|
||
'parent_id' => ['type' => 'integer', 'minimum' => 0],
|
||
'position' => ['type' => 'integer', 'minimum' => 0],
|
||
],
|
||
'required' => ['op'],
|
||
'additionalProperties' => false,
|
||
],
|
||
],
|
||
'confirm' => ['type' => 'boolean'],
|
||
],
|
||
'required' => ['menu_id'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
$tree = ['type' => 'array', 'items' => MenuTree::nodeSchema()];
|
||
|
||
return ToolResult::schema(['items' => $tree, 'previous_items' => $tree], ['items', 'previous_items']);
|
||
}
|
||
|
||
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
|
||
{
|
||
return current_user_can('edit_theme_options')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$menuId = (int) ($input['menu_id'] ?? 0);
|
||
$menu = wp_get_nav_menu_object($menuId);
|
||
if (! is_object($menu)) {
|
||
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
|
||
}
|
||
|
||
if (array_key_exists('items', $input) === array_key_exists('operations', $input)) {
|
||
return Errors::invalidInput(
|
||
'Entweder items (Gesamtbaum) oder operations angeben – genau eines von beiden.',
|
||
['items' => 'oder operations']
|
||
);
|
||
}
|
||
|
||
$currentItems = wp_get_nav_menu_items($menuId);
|
||
$current = [];
|
||
foreach (is_array($currentItems) ? $currentItems : [] as $item) {
|
||
$current[(int) $item->ID] = $item;
|
||
}
|
||
$previous = MenuTree::build(array_values($current));
|
||
|
||
$tree = array_key_exists('items', $input)
|
||
? (array) $input['items']
|
||
: $this->operations->apply($previous, (array) $input['operations']);
|
||
if ($tree instanceof WP_Error) {
|
||
return $tree;
|
||
}
|
||
|
||
$plan = $this->writer->plan($tree, $current);
|
||
if ($plan['errors'] !== []) {
|
||
return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']);
|
||
}
|
||
|
||
if ($plan['removals'] !== [] && ($input['confirm'] ?? false) !== true) {
|
||
return Errors::confirmRequired(
|
||
sprintf(
|
||
'Folgende Menüeinträge würden entfernt: %s.',
|
||
implode(', ', array_column($plan['removals'], 'title'))
|
||
),
|
||
['removals' => $plan['removals']]
|
||
);
|
||
}
|
||
|
||
$stats = $this->writer->apply($menuId, $tree, $current);
|
||
$after = wp_get_nav_menu_items($menuId);
|
||
$summary = sprintf(
|
||
'Menü "%s": %d neu, %d geändert, %d entfernt',
|
||
$menu->name,
|
||
$stats['created'],
|
||
$stats['updated'],
|
||
$stats['removed']
|
||
);
|
||
$logId = $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary);
|
||
|
||
return ToolResult::ok($summary . '.')
|
||
->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name)
|
||
->warnings($plan['warnings'])
|
||
->logId($logId)
|
||
->with('items', MenuTree::build(is_array($after) ? $after : []))
|
||
->with('previous_items', $previous)
|
||
->toArray();
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen
|
||
```php
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
|
||
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
|
||
```
|
||
und in `abilities()` nach `new MenuCreateAbility($this->log),` einfügen:
|
||
```php
|
||
new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log),
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS (lange Zeilen umbrechen).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: declarative and operation-based menu item editing
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 15: WordPress-Einstellungen – `SettingsWhitelist`, `lit/wp-settings-get`, `lit/wp-settings-update`
|
||
|
||
Whitelist exakt nach Spec §6.5; Recht `manage_options`. Nach einer Änderung der Permalink-Struktur werden die Rewrite-Regeln neu erzeugt – über `$wp_rewrite->set_permalink_structure()` (setzt Option und Rewrite-Objekt gemeinsam), danach `flush_rewrite_rules()`; ein bloßes `update_option()` + Flush würde die Regeln mit der alten Struktur erzeugen.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/WordPress/Settings/SettingsWhitelist.php`, `src/Integrations/WordPress/Settings/WpSettingsGetAbility.php`, `src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php`
|
||
- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php`, `tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ChangeLog`, `ToolResult`, `JsonMap`, `Errors`.
|
||
- Produces:
|
||
- `SettingsWhitelist` (final): `SETTINGS` (Schlüssel → `[Gruppe, Typ, Beschreibung]`), `BLOCKED`, `GROUPS`; `current(string $key): mixed` (`WPLANG` leer → `en_US`; Zahlenfelder als int); `validate(string $key, mixed $value, array $pending): ?string` (`null` = gültig; `$pending` = alle Werte dieses Aufrufs, für Abhängigkeiten `show_on_front`/`page_on_front`/`page_for_posts`); `normalize(string $key, mixed $value): mixed` (`WPLANG` `en_US` → `''`).
|
||
- `WpSettingsGetAbility(SettingsWhitelist $whitelist)` – `lit/wp-settings-get`, Input `{group?}`, Output `{settings: list<{key, group, type, value, description}>}`; indirekt; readonly.
|
||
- `WpSettingsUpdateAbility(SettingsWhitelist $whitelist, ChangeLog $log)` – `lit/wp-settings-update`, Input `{settings: {key: value}}`; alles oder nichts; Ergebnis-Extra `previous` (vorherige Werte); indirekt; idempotent.
|
||
|
||
- [ ] **Step 1: Failing Tests**
|
||
|
||
`tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Settings;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
|
||
final class SettingsWhitelistTest extends TestCase
|
||
{
|
||
public function testWhitelistMatchesTheSpecExactly(): void
|
||
{
|
||
self::assertSame(
|
||
[
|
||
'blogname', 'blogdescription', 'WPLANG', 'timezone_string', 'date_format', 'time_format',
|
||
'show_on_front', 'page_on_front', 'page_for_posts', 'posts_per_page',
|
||
'default_comment_status', 'default_ping_status', 'permalink_structure', 'site_icon',
|
||
],
|
||
array_keys(SettingsWhitelist::SETTINGS)
|
||
);
|
||
self::assertSame(['admin_email', 'users_can_register', 'default_role', 'siteurl', 'home'], SettingsWhitelist::BLOCKED);
|
||
}
|
||
|
||
public function testBlockedAndUnknownKeysAreRejected(): void
|
||
{
|
||
$whitelist = new SettingsWhitelist();
|
||
|
||
self::assertStringStartsWith('Gesperrt', (string) $whitelist->validate('admin_email', 'x@y.de', []));
|
||
self::assertStringStartsWith('Nicht freigegeben', (string) $whitelist->validate('template', 'twentyten', []));
|
||
}
|
||
|
||
public function testStaticFrontPageNeedsAPublishedDistinctPage(): void
|
||
{
|
||
Functions\when('get_option')->justReturn(0);
|
||
Functions\when('get_post')->alias(static fn (int $id) => match ($id) {
|
||
5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']),
|
||
6 => new WP_Post(['ID' => 6, 'post_type' => 'page', 'post_status' => 'draft']),
|
||
default => null,
|
||
});
|
||
$whitelist = new SettingsWhitelist();
|
||
|
||
self::assertNotNull($whitelist->validate('show_on_front', 'page', []));
|
||
self::assertNull($whitelist->validate('show_on_front', 'page', ['page_on_front' => 5]));
|
||
self::assertNull($whitelist->validate('page_on_front', 5, []));
|
||
self::assertNotNull($whitelist->validate('page_on_front', 6, []));
|
||
self::assertNotNull($whitelist->validate('page_on_front', 5, ['page_for_posts' => 5]));
|
||
self::assertNull($whitelist->validate('page_for_posts', 0, []));
|
||
}
|
||
|
||
public function testFormatsLanguagesAndRanges(): void
|
||
{
|
||
Functions\when('get_available_languages')->justReturn(['de_DE']);
|
||
Functions\when('wp_attachment_is_image')->alias(static fn (int $id): bool => $id === 33);
|
||
$whitelist = new SettingsWhitelist();
|
||
|
||
self::assertNull($whitelist->validate('WPLANG', 'de_DE', []));
|
||
self::assertNull($whitelist->validate('WPLANG', 'en_US', []));
|
||
self::assertStringContainsString('de_DE', (string) $whitelist->validate('WPLANG', 'fr_FR', []));
|
||
self::assertNull($whitelist->validate('timezone_string', 'Europe/Berlin', []));
|
||
self::assertNotNull($whitelist->validate('timezone_string', 'Mars/Base', []));
|
||
self::assertNull($whitelist->validate('permalink_structure', '/%postname%/', []));
|
||
self::assertNull($whitelist->validate('permalink_structure', '', []));
|
||
self::assertNotNull($whitelist->validate('permalink_structure', 'postname', []));
|
||
self::assertNotNull($whitelist->validate('posts_per_page', 0, []));
|
||
self::assertNotNull($whitelist->validate('default_comment_status', 'maybe', []));
|
||
self::assertNull($whitelist->validate('site_icon', 33, []));
|
||
self::assertNotNull($whitelist->validate('site_icon', 34, []));
|
||
self::assertSame('', $whitelist->normalize('WPLANG', 'en_US'));
|
||
}
|
||
|
||
public function testCurrentNormalizesLanguageAndNumbers(): void
|
||
{
|
||
Functions\when('get_option')->alias(static fn (string $key) => ['WPLANG' => '', 'page_on_front' => '5'][$key]);
|
||
$whitelist = new SettingsWhitelist();
|
||
|
||
self::assertSame('en_US', $whitelist->current('WPLANG'));
|
||
self::assertSame(5, $whitelist->current('page_on_front'));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Settings;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist;
|
||
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility;
|
||
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class WpSettingsAbilitiesTest extends TestCase
|
||
{
|
||
protected function tearDown(): void
|
||
{
|
||
unset($GLOBALS['wp_rewrite']);
|
||
parent::tearDown();
|
||
}
|
||
|
||
public function testGetFiltersByGroup(): void
|
||
{
|
||
Functions\when('get_option')->justReturn('Kunde');
|
||
|
||
$result = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'discussion']);
|
||
|
||
self::assertSame(['default_comment_status', 'default_ping_status'], array_column($result['settings'], 'key'));
|
||
self::assertSame('discussion', $result['settings'][0]['group']);
|
||
}
|
||
|
||
public function testUpdateIsAllOrNothing(): void
|
||
{
|
||
Functions\expect('update_option')->never();
|
||
|
||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))
|
||
->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]);
|
||
|
||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||
self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']);
|
||
}
|
||
|
||
public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void
|
||
{
|
||
Functions\when('get_option')->alias(static fn (string $key) => [
|
||
'blogname' => 'Alt',
|
||
'blogdescription' => 'Gleich',
|
||
'permalink_structure' => '',
|
||
][$key]);
|
||
Functions\expect('update_option')->once()->with('blogname', 'Neu');
|
||
$rewrite = Mockery::mock();
|
||
$rewrite->expects('set_permalink_structure')->with('/%postname%/');
|
||
$GLOBALS['wp_rewrite'] = $rewrite;
|
||
Functions\expect('flush_rewrite_rules')->once();
|
||
$log = Mockery::mock(ChangeLog::class);
|
||
$log->expects('record')->with('lit/wp-settings-update', 'option', 'blogname,permalink_structure', Mockery::type('string'))->andReturn(30);
|
||
|
||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [
|
||
'blogname' => 'Neu',
|
||
'blogdescription' => 'Gleich',
|
||
'permalink_structure' => '/%postname%/',
|
||
]]);
|
||
|
||
self::assertSame(['blogname', 'permalink_structure'], array_column($result['changed'], 'id'));
|
||
self::assertSame(['blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => ''], $result['previous']);
|
||
self::assertSame(30, $result['log_id']);
|
||
}
|
||
|
||
public function testSettingsToolsNeedManageOptions(): void
|
||
{
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options');
|
||
|
||
self::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code());
|
||
self::assertSame(
|
||
'lit_forbidden',
|
||
(new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))->permission([])->get_error_code()
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `WordPressIntegrationTest` die Konstante um die beiden Einstellungs-Tools am Ende ergänzen:
|
||
```php
|
||
private const EXPECTED = [
|
||
'lit/content-types',
|
||
'lit/content-query',
|
||
'lit/content-get',
|
||
'lit/content-create',
|
||
'lit/content-update',
|
||
'lit/post-publish',
|
||
'lit/post-unpublish',
|
||
'lit/post-schedule',
|
||
'lit/post-trash',
|
||
'lit/post-duplicate',
|
||
'lit/post-replace-with-copy',
|
||
'lit/revisions-list',
|
||
'lit/revision-restore',
|
||
'lit/menu-list',
|
||
'lit/menu-get',
|
||
'lit/menu-create',
|
||
'lit/menu-set-items',
|
||
'lit/menu-assign-location',
|
||
'lit/wp-settings-get',
|
||
'lit/wp-settings-update',
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Run – muss fehlschlagen**
|
||
|
||
Run: `composer test -- --filter 'Settings|WordPressIntegrationTest'`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implementierung**
|
||
|
||
`src/Integrations/WordPress/Settings/SettingsWhitelist.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Settings;
|
||
|
||
use WP_Post;
|
||
|
||
/**
|
||
* The WordPress options LIT MCP may read and write (spec §6.5), with validation.
|
||
*/
|
||
final class SettingsWhitelist
|
||
{
|
||
/** key => [group, type, description] */
|
||
public const SETTINGS = [
|
||
'blogname' => ['general', 'string', 'Titel der Website'],
|
||
'blogdescription' => ['general', 'string', 'Untertitel der Website'],
|
||
'WPLANG' => ['general', 'string', 'Sprache (Locale, z. B. de_DE; en_US = Englisch); nur installierte Sprachen'],
|
||
'timezone_string' => ['general', 'string', 'Zeitzone, z. B. Europe/Berlin'],
|
||
'date_format' => ['general', 'string', 'Datumsformat (PHP), z. B. j. F Y'],
|
||
'time_format' => ['general', 'string', 'Zeitformat (PHP), z. B. H:i'],
|
||
'show_on_front' => ['reading', 'string', 'Startseite: posts (neueste Beiträge) oder page (statische Seite)'],
|
||
'page_on_front' => ['reading', 'integer', 'ID der statischen Startseite (0 = keine)'],
|
||
'page_for_posts' => ['reading', 'integer', 'ID der Beitragsseite (0 = keine)'],
|
||
'posts_per_page' => ['reading', 'integer', 'Beiträge pro Seite (1–100)'],
|
||
'default_comment_status' => ['discussion', 'string', 'Kommentare für neue Beiträge: open oder closed'],
|
||
'default_ping_status' => ['discussion', 'string', 'Pingbacks für neue Beiträge: open oder closed'],
|
||
'permalink_structure' => ['permalinks', 'string', 'Permalink-Struktur, z. B. /%postname%/ (leer = einfach)'],
|
||
'site_icon' => ['site_icon', 'integer', 'Anhang-ID des Website-Icons (Bild, empfohlen 512×512; 0 = keins)'],
|
||
];
|
||
|
||
public const BLOCKED = ['admin_email', 'users_can_register', 'default_role', 'siteurl', 'home'];
|
||
public const GROUPS = ['general', 'reading', 'discussion', 'permalinks', 'site_icon'];
|
||
|
||
private const INTEGER_KEYS = ['page_on_front', 'page_for_posts', 'posts_per_page', 'site_icon'];
|
||
|
||
public function current(string $key): mixed
|
||
{
|
||
$value = get_option($key);
|
||
if ($key === 'WPLANG') {
|
||
return $value === '' || $value === false ? 'en_US' : (string) $value;
|
||
}
|
||
|
||
return in_array($key, self::INTEGER_KEYS, true) ? (int) $value : (string) $value;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $pending All values of the current call.
|
||
* @return string|null Error message, null when valid.
|
||
*/
|
||
public function validate(string $key, mixed $value, array $pending): ?string
|
||
{
|
||
if (in_array($key, self::BLOCKED, true)) {
|
||
return 'Gesperrt: aus Sicherheitsgründen nicht über LIT MCP änderbar.';
|
||
}
|
||
|
||
if (! isset(self::SETTINGS[$key])) {
|
||
return 'Nicht freigegeben; erlaubt: ' . implode(', ', array_keys(self::SETTINGS)) . '.';
|
||
}
|
||
|
||
return match ($key) {
|
||
'blogname', 'blogdescription' => is_string($value) && mb_strlen($value) <= 200
|
||
? null
|
||
: 'Text bis 200 Zeichen erwartet.',
|
||
'WPLANG' => $this->checkLanguage($value),
|
||
'timezone_string' => is_string($value) && in_array($value, timezone_identifiers_list(), true)
|
||
? null
|
||
: 'Zeitzonen-Kennung wie Europe/Berlin erwartet.',
|
||
'date_format', 'time_format' => is_string($value) && trim($value) !== '' && mb_strlen($value) <= 50
|
||
? null
|
||
: 'Nicht leeres Format bis 50 Zeichen erwartet.',
|
||
'show_on_front' => $this->checkShowOnFront($value, $pending),
|
||
'page_on_front', 'page_for_posts' => $this->checkPage($key, $value, $pending),
|
||
'posts_per_page' => is_int($value) && $value >= 1 && $value <= 100
|
||
? null
|
||
: 'Ganzzahl von 1 bis 100 erwartet.',
|
||
'default_comment_status', 'default_ping_status' => in_array($value, ['open', 'closed'], true)
|
||
? null
|
||
: 'open oder closed erwartet.',
|
||
'permalink_structure' => $this->checkPermalink($value),
|
||
'site_icon' => $this->checkSiteIcon($value),
|
||
};
|
||
}
|
||
|
||
public function normalize(string $key, mixed $value): mixed
|
||
{
|
||
return $key === 'WPLANG' && $value === 'en_US' ? '' : $value;
|
||
}
|
||
|
||
private function checkLanguage(mixed $value): ?string
|
||
{
|
||
if ($value === 'en_US' || $value === '') {
|
||
return null;
|
||
}
|
||
|
||
$installed = get_available_languages();
|
||
|
||
return is_string($value) && in_array($value, $installed, true)
|
||
? null
|
||
: sprintf('Sprache nicht installiert; verfügbar: en_US, %s.', implode(', ', $installed));
|
||
}
|
||
|
||
/** @param array<string, mixed> $pending */
|
||
private function checkShowOnFront(mixed $value, array $pending): ?string
|
||
{
|
||
if ($value === 'posts') {
|
||
return null;
|
||
}
|
||
|
||
if ($value !== 'page') {
|
||
return 'posts oder page erwartet.';
|
||
}
|
||
|
||
$front = (int) ($pending['page_on_front'] ?? get_option('page_on_front'));
|
||
|
||
return $front > 0 ? null : 'Für page zuerst page_on_front setzen (im selben Aufruf möglich).';
|
||
}
|
||
|
||
/** @param array<string, mixed> $pending */
|
||
private function checkPage(string $key, mixed $value, array $pending): ?string
|
||
{
|
||
if (! is_int($value) || $value < 0) {
|
||
return 'Seiten-ID (0 = keine) erwartet.';
|
||
}
|
||
|
||
if ($value === 0) {
|
||
return null;
|
||
}
|
||
|
||
$page = get_post($value);
|
||
if (! $page instanceof WP_Post || $page->post_type !== 'page' || $page->post_status !== 'publish') {
|
||
return sprintf('%d ist keine veröffentlichte Seite.', $value);
|
||
}
|
||
|
||
$other = $key === 'page_on_front' ? 'page_for_posts' : 'page_on_front';
|
||
$otherValue = (int) ($pending[$other] ?? get_option($other));
|
||
|
||
return $otherValue === $value ? 'Startseite und Beitragsseite müssen verschiedene Seiten sein.' : null;
|
||
}
|
||
|
||
private function checkPermalink(mixed $value): ?string
|
||
{
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
|
||
return is_string($value)
|
||
&& str_starts_with($value, '/')
|
||
&& str_contains($value, '%')
|
||
&& mb_strlen($value) <= 200
|
||
&& preg_match('/\s/', $value) !== 1
|
||
? null
|
||
: 'Leer oder eine Struktur wie /%postname%/ erwartet.';
|
||
}
|
||
|
||
private function checkSiteIcon(mixed $value): ?string
|
||
{
|
||
if (! is_int($value) || $value < 0) {
|
||
return 'Anhang-ID (0 = keins) erwartet.';
|
||
}
|
||
|
||
return $value === 0 || wp_attachment_is_image($value)
|
||
? null
|
||
: sprintf('%d ist kein Bild der Mediathek.', $value);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Settings/WpSettingsGetAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Settings;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
final class WpSettingsGetAbility implements Ability
|
||
{
|
||
public function __construct(private SettingsWhitelist $whitelist)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/wp-settings-get';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'WordPress-Einstellungen lesen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Liest die freigegebenen WordPress-Einstellungen (Allgemein, Lesen, Diskussion, Permalinks, '
|
||
. 'Website-Icon) mit Typ und Beschreibung. Ändern mit lit-wp-settings-update.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => ['group' => ['type' => 'string', 'enum' => SettingsWhitelist::GROUPS]],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'settings' => [
|
||
'type' => 'array',
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'key' => ['type' => 'string'],
|
||
'group' => ['type' => 'string'],
|
||
'type' => ['type' => 'string'],
|
||
'value' => ['type' => ['string', 'integer']],
|
||
'description' => ['type' => 'string'],
|
||
],
|
||
'required' => ['key', 'group', 'type', 'value', 'description'],
|
||
],
|
||
],
|
||
],
|
||
'required' => ['settings'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return current_user_can('manage_options')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$group = isset($input['group']) ? (string) $input['group'] : '';
|
||
$settings = [];
|
||
foreach (SettingsWhitelist::SETTINGS as $key => [$settingGroup, $type, $description]) {
|
||
if ($group !== '' && $settingGroup !== $group) {
|
||
continue;
|
||
}
|
||
|
||
$settings[] = [
|
||
'key' => $key,
|
||
'group' => $settingGroup,
|
||
'type' => $type,
|
||
'value' => $this->whitelist->current($key),
|
||
'description' => $description,
|
||
];
|
||
}
|
||
|
||
return ['settings' => $settings];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php`:
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Settings;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\JsonMap;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use WP_Error;
|
||
|
||
final class WpSettingsUpdateAbility implements Ability
|
||
{
|
||
public function __construct(private SettingsWhitelist $whitelist, private ChangeLog $log)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/wp-settings-update';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'WordPress-Einstellungen ändern';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Ändert freigegebene WordPress-Einstellungen (Schlüssel siehe lit-wp-settings-get). Alle Werte '
|
||
. 'werden vorher geprüft – bei einem Fehler wird nichts gespeichert. previous enthält die alten Werte. '
|
||
. 'Gesperrt: admin_email, users_can_register, default_role, siteurl, home.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'settings' => [
|
||
'type' => 'object',
|
||
'minProperties' => 1,
|
||
'description' => 'Schlüssel → neuer Wert, z. B. {"blogname": "Praxis Muster"}.',
|
||
],
|
||
],
|
||
'required' => ['settings'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema(['previous' => ['type' => 'object']], ['previous']);
|
||
}
|
||
|
||
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
|
||
{
|
||
return current_user_can('manage_options')
|
||
? true
|
||
: Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$settings = is_array($input['settings'] ?? null) ? $input['settings'] : [];
|
||
if ($settings === []) {
|
||
return Errors::invalidInput('Keine Einstellungen angegeben.', ['settings' => 'mindestens ein Schlüssel']);
|
||
}
|
||
|
||
$errors = [];
|
||
foreach ($settings as $key => $value) {
|
||
$error = $this->whitelist->validate((string) $key, $value, $settings);
|
||
if ($error !== null) {
|
||
$errors[(string) $key] = $error;
|
||
}
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
return Errors::invalidInput(
|
||
'Einstellungen ungültig; nichts wurde gespeichert.',
|
||
$errors,
|
||
'Werte gemäß fields korrigieren; aktuelle Werte über lit-wp-settings-get.'
|
||
);
|
||
}
|
||
|
||
$previous = [];
|
||
$changed = [];
|
||
foreach ($settings as $key => $value) {
|
||
$key = (string) $key;
|
||
$previous[$key] = $this->whitelist->current($key);
|
||
if ($previous[$key] === $value) {
|
||
continue;
|
||
}
|
||
|
||
if ($key === 'permalink_structure') {
|
||
$this->setPermalinkStructure((string) $value);
|
||
} else {
|
||
update_option($key, $this->whitelist->normalize($key, $value));
|
||
}
|
||
$changed[] = $key;
|
||
}
|
||
|
||
if ($changed === []) {
|
||
return ToolResult::ok('Keine Änderung – alle Werte waren bereits gesetzt.')
|
||
->with('previous', JsonMap::of($previous))
|
||
->toArray();
|
||
}
|
||
|
||
$summary = 'Einstellungen geändert: ' . implode(', ', $changed);
|
||
$logId = $this->log->record($this->name(), 'option', implode(',', $changed), $summary);
|
||
|
||
$result = ToolResult::ok($summary . '.');
|
||
foreach ($changed as $key) {
|
||
$result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]);
|
||
}
|
||
|
||
return $result->logId($logId)->with('previous', JsonMap::of($previous))->toArray();
|
||
}
|
||
|
||
private function setPermalinkStructure(string $structure): void
|
||
{
|
||
global $wp_rewrite;
|
||
$wp_rewrite->set_permalink_structure($structure);
|
||
flush_rewrite_rules();
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen
|
||
```php
|
||
use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist;
|
||
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility;
|
||
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility;
|
||
```
|
||
und `abilities()` durch die vollständige Endfassung ersetzen:
|
||
```php
|
||
public function abilities(): array
|
||
{
|
||
$presenter = new PostPresenter($this->schema);
|
||
$writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner());
|
||
$cloner = new PostCloner();
|
||
$whitelist = new SettingsWhitelist();
|
||
|
||
return [
|
||
new ContentTypesAbility($this->schema),
|
||
new ContentQueryAbility($this->schema, $presenter),
|
||
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),
|
||
new PostDuplicateAbility($this->schema, $cloner, $this->log),
|
||
new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
|
||
new RevisionsListAbility($this->schema),
|
||
new RevisionRestoreAbility($this->schema, $this->log),
|
||
new MenuListAbility(),
|
||
new MenuGetAbility(),
|
||
new MenuCreateAbility($this->log),
|
||
new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log),
|
||
new MenuAssignLocationAbility($this->log),
|
||
new WpSettingsGetAbility($whitelist),
|
||
new WpSettingsUpdateAbility($whitelist, $this->log),
|
||
];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run – muss bestehen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress tests/Unit/Integrations/WordPress
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: whitelisted WordPress settings tools
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
### Task 16: Anleitungen und Ende-zu-Ende-Test über HTTP
|
||
|
||
Die Anleitungen `content-types` und `workflows` (Spec §9.2) beschreiben die neuen Tools für Claude; der Ende-zu-Ende-Test ruft jede Ability dieses Plans über den echten OAuth-Endpunkt auf. Da WordPress jede Ausgabe gegen das `outputSchema` prüft, ist das zugleich der automatische Output-Schema-Test aus Spec §11.4.
|
||
|
||
**Files:**
|
||
- Create: `docs/guides/content-types.md`, `docs/guides/workflows.md`, `tests/Integration/lib.sh`, `tests/Integration/wp-basis-smoke.sh`
|
||
- Modify: `docs/guides/overview.md` (Abschnitt „Verfügbare Werkzeuge“ ersetzen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: alle Abilities der Tasks 6–15; `tests/Integration/mint-token.php`, `tests/Integration/pods-fixture.php`.
|
||
- Produces: `tests/Integration/lib.sh` mit `fail`, `pass`, `cleanup_app_passwords_for <login>`, `connect <login>` (setzt `TOKEN`, `SESSION`, `INIT_RESPONSE`), `rpc <method> <params-json>`, `tool <name> <args-json>` (gibt `structuredContent` oder `{"__error": "<text>"}` aus), `ability <lit/name> <params-json>` (über `mcp-adapter-execute-ability`; gibt das Ergebnis oder `{"__error": …}` aus), `error_code <json>` (liefert z. B. `rev_mismatch`) – Etappe 1b nutzt dieselben Helfer.
|
||
|
||
- [ ] **Step 1: Anleitungen**
|
||
|
||
`docs/guides/content-types.md`:
|
||
```markdown
|
||
# Inhalte, Felder und Status
|
||
|
||
## Inhaltstypen erkennen
|
||
|
||
- `lit-content-types` ohne Parameter listet alle Inhaltstypen (Seiten, Beiträge, eigene Typen) mit
|
||
Taxonomien, Anzahl und ob Divi dafür aktiv ist.
|
||
- `lit-content-types` mit `post_type` liefert zusätzlich alle Felder: Schlüssel, Typ, Pflicht, Auswahlwerte
|
||
(`choices`), Beziehungsziel (`related`) und Schreibstatus:
|
||
- `writable` – sicher beschreibbar (Pods-Feld oder registriertes Meta-Feld),
|
||
- `readonly` – nicht beschreibbar,
|
||
- `unknown` – vorhandener Meta-Schlüssel ohne bekannten Typ; Schreiben geht, der Wert wird unverändert
|
||
gespeichert (Warnung in der Antwort).
|
||
|
||
## Lesen und Suchen
|
||
|
||
- `lit-content-query`: Filter `status`, `search`, `taxonomy` (`{taxonomy, terms: [IDs oder Slugs]}`),
|
||
`field` (`{key, value, compare}`), `date_after`/`date_before` (JJJJ-MM-TT), `parent`; Blättern mit
|
||
`next_cursor` → `cursor`.
|
||
- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`.
|
||
Bei Divi-Seiten fehlt `content`; `hints` verweist auf `lit-divi-outline`.
|
||
|
||
## Anlegen und Ändern
|
||
|
||
- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler
|
||
bricht den ganzen Aufruf ab (`fields.<schlüssel>` im Fehler nennt die Ursache).
|
||
- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get`
|
||
mitgeben – hat jemand parallel gespeichert, kommt `rev_mismatch`: neu lesen, Änderung wiederholen.
|
||
- Begriffe: `terms: {"category": [4, "Neuer Begriff"]}` ersetzt die Zuordnung dieser Taxonomie (IDs oder
|
||
Namen; unbekannte Namen werden angelegt, falls erlaubt).
|
||
- Felder leeren: Wert `null`. Pflichtfelder können nicht geleert werden.
|
||
- Beziehungsfelder erwarten Eintrags-IDs, Datei-/Bildfelder Anhang-IDs aus der Mediathek.
|
||
|
||
## Status
|
||
|
||
- Veröffentlichen, Zurückziehen und Planen sind eigene Werkzeuge (`lit-post-publish`, `lit-post-unpublish`,
|
||
`lit-post-schedule` mit `date` = „JJJJ-MM-TT HH:MM“ in der Zeitzone der Website). Nur auf ausdrücklichen
|
||
Wunsch des Nutzers verwenden.
|
||
- `lit-post-trash` legt in den Papierkorb (nie endgültig) und braucht `confirm: true` nach Rückfrage.
|
||
|
||
## Sicher überarbeiten
|
||
|
||
1. `lit-post-duplicate` → Entwurfskopie.
|
||
2. Kopie mit `lit-content-update` (oder den Divi-Tools) bearbeiten und dem Nutzer die Vorschau zeigen.
|
||
3. `lit-post-replace-with-copy` mit `confirm: true` übernimmt die Kopie ins Original (ID, URL, Status und
|
||
Menüeinträge bleiben; vorher entsteht eine Revision).
|
||
|
||
Jede Änderung liefert `revision_id` (Stand vorher) und `log_id`. Rückgängig: `lit-revisions-list` →
|
||
`lit-revision-restore` mit `confirm: true`. Revisionen umfassen Titel, Inhalt und Auszug – Felder und
|
||
Begriffe nicht.
|
||
```
|
||
|
||
`docs/guides/workflows.md`:
|
||
```markdown
|
||
# Abläufe
|
||
|
||
## Neue Website aufsetzen
|
||
|
||
1. `lit-site-status` und `lit-content-types` lesen.
|
||
2. Grundeinstellungen: `lit-wp-settings-get`, dann `lit-wp-settings-update` (Titel, Untertitel, Sprache,
|
||
Zeitzone, Datumsformat, Permalinks `/%postname%/`). `previous` in der Antwort enthält die alten Werte.
|
||
3. Seiten als Entwürfe anlegen (`lit-content-create`), Hierarchie über `parent`.
|
||
4. Startseite: Seite veröffentlichen (nach Rückfrage), dann
|
||
`{"settings": {"page_on_front": <ID>, "show_on_front": "page"}}`.
|
||
5. Menü: `lit-menu-create` (optional mit `location` aus `lit-menu-list`), Einträge mit `lit-menu-set-items`.
|
||
6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte.
|
||
|
||
## Menüs pflegen
|
||
|
||
- `lit-menu-get` liefert den Baum. Für größere Umbauten den Baum verändern und vollständig als `items` an
|
||
`lit-menu-set-items` senden (bestehende Einträge mit `id`, neue ohne `id`).
|
||
- Für kleine Änderungen `operations`: `{op: "add", item, parent_id?, position?}`,
|
||
`{op: "move", id, parent_id?, position?}`, `{op: "remove", id}`.
|
||
- Fallen Einträge weg, verlangt das Tool `confirm: true`; `previous_items` erlaubt das Zurückbauen.
|
||
|
||
## Einträge eines eigenen Inhaltstyps (Pods)
|
||
|
||
1. `lit-content-types` mit `post_type` → Pflichtfelder, Auswahlwerte, Beziehungsziele.
|
||
2. `lit-content-create` mit allen Pflichtfeldern in `fields`.
|
||
3. Beziehungen über IDs (vorher mit `lit-content-query` suchen).
|
||
```
|
||
|
||
`docs/guides/overview.md`: den Abschnitt ab `## Verfügbare Werkzeuge (Etappe 0)` bis zum Dateiende ersetzen durch:
|
||
```markdown
|
||
## Werkzeuge
|
||
|
||
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
|
||
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`.
|
||
|
||
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`):
|
||
Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`,
|
||
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
|
||
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
||
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`).
|
||
|
||
Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`.
|
||
|
||
## Rückmeldungen
|
||
|
||
- Erfolg: `ok`, `summary`, `changed`, `warnings`, `revision_id` (Stand vorher), `log_id`, URLs.
|
||
- Fehler: JSON mit `code`, `message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
|
||
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
|
||
```
|
||
|
||
- [ ] **Step 2: Gemeinsame Shell-Helfer**
|
||
|
||
`tests/Integration/lib.sh`:
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# Shared helpers for the MCP integration scripts. Source it from a script that
|
||
# runs INSIDE the DDEV web container with /var/www/html as working directory.
|
||
# Requires jq and curl (both present in the DDEV web image).
|
||
|
||
PLUGIN=wp-content/plugins/WP-LIT-MCP
|
||
ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server
|
||
TOKEN=""
|
||
SESSION=""
|
||
INIT_RESPONSE=""
|
||
REQUEST_ID=100
|
||
|
||
fail() { echo "FAIL: $*"; exit 1; }
|
||
pass() { echo "ok - $*"; }
|
||
|
||
# Removes every "lit-mcp-integration-test" application password of a user.
|
||
cleanup_app_passwords_for() { # $1 = user_login
|
||
local login="$1"
|
||
[ -n "$login" ] || return 0
|
||
local escaped=${login//\\/\\\\}
|
||
escaped=${escaped//\'/\\\'}
|
||
wp eval "
|
||
\$u = get_user_by('login', '${escaped}');
|
||
if (\$u) {
|
||
foreach (WP_Application_Passwords::get_user_application_passwords(\$u->ID) as \$pw) {
|
||
if ((\$pw['name'] ?? '') === 'lit-mcp-integration-test') {
|
||
WP_Application_Passwords::delete_application_password(\$u->ID, \$pw['uuid']);
|
||
}
|
||
}
|
||
}
|
||
" >/dev/null 2>&1 || true
|
||
}
|
||
|
||
# Mints a token for the user and opens an MCP session. Sets TOKEN, SESSION and
|
||
# INIT_RESPONSE (raw initialize response incl. headers).
|
||
connect() { # $1 = user_login
|
||
TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$1")
|
||
local init
|
||
init=$(curl -s --max-time 20 -i -X POST "$ENDPOINT" \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-H 'Content-Type: application/json' \
|
||
-H 'Accept: application/json, text/event-stream' \
|
||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}')
|
||
SESSION=$(echo "$init" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true)
|
||
INIT_RESPONSE="$init"
|
||
echo "$init" | grep -q '"protocolVersion"' || fail "initialize failed: $init"
|
||
}
|
||
|
||
# Raw JSON-RPC call; prints the response body.
|
||
rpc() { # $1 = method, $2 = params JSON
|
||
REQUEST_ID=$((REQUEST_ID + 1))
|
||
local body
|
||
body=$(jq -cn --arg m "$1" --argjson p "$2" --argjson id "$REQUEST_ID" '{jsonrpc: "2.0", id: $id, method: $m, params: $p}')
|
||
local session_header=()
|
||
[ -n "$SESSION" ] && session_header=(-H "Mcp-Session-Id: $SESSION")
|
||
curl -s --max-time 60 -X POST "$ENDPOINT" \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-H 'Content-Type: application/json' \
|
||
-H 'Accept: application/json, text/event-stream' \
|
||
"${session_header[@]}" -d "$body"
|
||
}
|
||
|
||
# Calls a tool; prints structuredContent, or {"__error": "<text>"} for isError results.
|
||
tool() { # $1 = tool name, $2 = arguments JSON
|
||
local response
|
||
response=$(rpc tools/call "$(jq -cn --arg n "$1" --argjson a "$2" '{name: $n, arguments: $a}')")
|
||
echo "$response" | jq -e '.result' >/dev/null 2>&1 || fail "$1: protocol error: $response"
|
||
if [ "$(echo "$response" | jq -r '.result.isError')" = "true" ]; then
|
||
echo "$response" | jq -c '{__error: .result.content[0].text}'
|
||
else
|
||
echo "$response" | jq -c '.result.structuredContent'
|
||
fi
|
||
}
|
||
|
||
# Runs an indirect ability through mcp-adapter-execute-ability; prints its result or {"__error": …}.
|
||
ability() { # $1 = ability name (lit/…), $2 = parameters JSON
|
||
local out
|
||
out=$(tool mcp-adapter-execute-ability "$(jq -cn --arg n "$1" --argjson p "$2" '{ability_name: $n, parameters: $p}')")
|
||
if echo "$out" | jq -e 'has("__error")' >/dev/null; then
|
||
echo "$out"
|
||
elif [ "$(echo "$out" | jq -r '.success')" = "true" ]; then
|
||
echo "$out" | jq -c '.data'
|
||
else
|
||
echo "$out" | jq -c '{__error: .error}'
|
||
fi
|
||
}
|
||
|
||
# Prints the lit error code (e.g. rev_mismatch) of a tool()/ability() result, or "".
|
||
error_code() { # $1 = result JSON
|
||
echo "$1" | jq -r '(.__error // "") | (try fromjson catch {}) | (.code // "")'
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Ende-zu-Ende-Skript**
|
||
|
||
`tests/Integration/wp-basis-smoke.sh`:
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# End-to-end checks of the Etappe-1a tools (content, status, copies, revisions,
|
||
# menus, WordPress settings, change log) through the OAuth-protected endpoint.
|
||
# Runs INSIDE the DDEV web container:
|
||
# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh
|
||
# Temporarily activates the installed Pods plugin (restored afterwards) and
|
||
# restores every setting/menu location it changes.
|
||
set -euo pipefail
|
||
cd /var/www/html
|
||
# shellcheck source=lib.sh
|
||
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
|
||
|
||
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
|
||
PODS_WAS_ACTIVE=no
|
||
wp plugin is-active pods && PODS_WAS_ACTIVE=yes
|
||
ORIGINAL_DESCRIPTION=$(wp option get blogdescription)
|
||
ORIGINAL_LOCATIONS=$(wp eval 'echo wp_json_encode(get_nav_menu_locations());')
|
||
CREATED_POSTS=()
|
||
MENU_ID=""
|
||
|
||
cleanup() {
|
||
for id in "${CREATED_POSTS[@]}"; do
|
||
wp post delete "$id" --force >/dev/null 2>&1 || true
|
||
done
|
||
[ -n "$MENU_ID" ] && wp menu delete "$MENU_ID" >/dev/null 2>&1 || true
|
||
wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1 || true
|
||
LIT_LOCATIONS="$ORIGINAL_LOCATIONS" wp eval \
|
||
'set_theme_mod("nav_menu_locations", (array) json_decode(getenv("LIT_LOCATIONS"), true));' >/dev/null 2>&1 || true
|
||
wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" delete >/dev/null 2>&1 || true
|
||
[ "$PODS_WAS_ACTIVE" = "no" ] && wp plugin deactivate pods >/dev/null 2>&1 || true
|
||
cleanup_app_passwords_for "$ADMIN_LOGIN"
|
||
}
|
||
trap cleanup EXIT
|
||
|
||
wp plugin activate pods >/dev/null
|
||
wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" create >/dev/null
|
||
connect "$ADMIN_LOGIN"
|
||
|
||
# 1. Tool list: direct tools visible with annotations, the rest only via meta-tools
|
||
LIST=$(rpc tools/list '{}')
|
||
for t in lit-content-types lit-content-query lit-content-get lit-content-create lit-content-update lit-post-publish; do
|
||
echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t"
|
||
done
|
||
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-post-trash")' >/dev/null \
|
||
&& fail "lit-post-trash must only be reachable via execute-ability"
|
||
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-content-get") | .annotations.readOnlyHint == true' >/dev/null \
|
||
|| fail "lit-content-get lacks readOnlyHint"
|
||
pass "tools/list: direct content tools with annotations, indirect status tools"
|
||
|
||
# 2. Schema detection incl. Pods
|
||
TYPES=$(tool lit-content-types '{}')
|
||
echo "$TYPES" | jq -e '.post_types[] | select(.name == "page")' >/dev/null || fail "content-types lacks page: $TYPES"
|
||
PERSON_TYPE=$(tool lit-content-types '{"post_type":"lit_person"}')
|
||
echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "role" and .required == true and .source == "pods")' >/dev/null \
|
||
|| fail "Pods field role missing: $PERSON_TYPE"
|
||
echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "level" and (.choices | length) == 2)' >/dev/null \
|
||
|| fail "Pods choices missing: $PERSON_TYPE"
|
||
pass "content-types lists post types and Pods fields"
|
||
|
||
# 3. Create, read, update with rev lock
|
||
CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke Seite","content":"<p>Hallo</p>"}')
|
||
PAGE_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty')
|
||
[ -n "$PAGE_ID" ] || fail "content-create: $CREATED"
|
||
CREATED_POSTS+=("$PAGE_ID")
|
||
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "new page is not a draft"
|
||
REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev')
|
||
STALE=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"000000000000\",\"title\":\"x\"}")
|
||
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale rev accepted: $STALE"
|
||
UPDATED=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"$REV\",\"content\":\"<p>Neu</p>\"}")
|
||
echo "$UPDATED" | jq -e '.ok == true and .log_id != null' >/dev/null || fail "content-update: $UPDATED"
|
||
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "update changed the status"
|
||
pass "content create/get/update, rev lock, status unchanged"
|
||
|
||
# 4. Pods entries: required field enforced, values written through Pods
|
||
MISSING=$(tool lit-content-create '{"post_type":"lit_person","title":"Ohne Funktion"}')
|
||
[ "$(error_code "$MISSING")" = "validation_failed" ] || fail "missing required Pods field accepted: $MISSING"
|
||
PERSON=$(tool lit-content-create '{"post_type":"lit_person","title":"Anna","fields":{"role":"Leitung","level":"senior"}}')
|
||
PERSON_ID=$(echo "$PERSON" | jq -r '.changed[0].id // empty')
|
||
[ -n "$PERSON_ID" ] || fail "Pods entry: $PERSON"
|
||
CREATED_POSTS+=("$PERSON_ID")
|
||
tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -e '.fields.role == "Leitung" and .fields.level == "senior"' >/dev/null \
|
||
|| fail "Pods values not readable"
|
||
pass "Pods entry with validated fields"
|
||
|
||
# 5. Status tools
|
||
tool lit-post-publish "{\"post_id\":$PAGE_ID}" | jq -e '.changed[0].action == "published"' >/dev/null || fail "publish"
|
||
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "page not published"
|
||
ability lit/post-unpublish "{\"post_id\":$PAGE_ID}" | jq -e '.ok == true' >/dev/null || fail "unpublish"
|
||
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "page not back to draft"
|
||
FUTURE=$(date -d '+2 days' '+%Y-%m-%d 10:00')
|
||
ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"$FUTURE\"}" | jq -e '.ok == true' >/dev/null || fail "schedule"
|
||
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "future" ] || fail "page not scheduled"
|
||
PAST=$(ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"2020-01-01 10:00\"}")
|
||
[ "$(error_code "$PAST")" = "validation_failed" ] || fail "past schedule accepted: $PAST"
|
||
tool lit-post-publish "{\"post_id\":$PAGE_ID}" >/dev/null
|
||
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "scheduled page did not publish now"
|
||
pass "publish/unpublish/schedule, past dates rejected"
|
||
|
||
# 6. Copy and replace
|
||
COPY=$(ability lit/post-duplicate "{\"post_id\":$PAGE_ID}")
|
||
COPY_ID=$(echo "$COPY" | jq -r '.changed[0].id // empty')
|
||
[ -n "$COPY_ID" ] || fail "duplicate: $COPY"
|
||
CREATED_POSTS+=("$COPY_ID")
|
||
tool lit-content-update "{\"post_id\":$COPY_ID,\"content\":\"<p>Aus Kopie</p>\"}" >/dev/null
|
||
NOCONFIRM=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID}")
|
||
[ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "replace without confirm: $NOCONFIRM"
|
||
ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"confirm\":true}" \
|
||
| jq -e '.revision_id != null' >/dev/null || fail "replace-with-copy"
|
||
[ "$(wp post get "$PAGE_ID" --field=post_content)" = "<p>Aus Kopie</p>" ] || fail "original not replaced"
|
||
[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "replace changed the original's status"
|
||
[ "$(wp post get "$COPY_ID" --field=post_status)" = "trash" ] || fail "copy not trashed"
|
||
pass "duplicate and replace-with-copy (status kept, copy trashed)"
|
||
|
||
# 7. Revisions
|
||
REVISIONS=$(ability lit/revisions-list "{\"post_id\":$PAGE_ID}")
|
||
OLDEST=$(echo "$REVISIONS" | jq -r '.items[-1].id // empty')
|
||
[ -n "$OLDEST" ] || fail "revisions-list: $REVISIONS"
|
||
ability lit/revision-restore "{\"revision_id\":$OLDEST,\"confirm\":true}" | jq -e '.ok == true' >/dev/null \
|
||
|| fail "revision-restore"
|
||
pass "revisions list and restore"
|
||
|
||
# 8. Trash
|
||
ability lit/post-trash "{\"post_id\":$PERSON_ID,\"confirm\":true}" | jq -e '.changed[0].action == "trashed"' >/dev/null \
|
||
|| fail "post-trash"
|
||
[ "$(wp post get "$PERSON_ID" --field=post_status)" = "trash" ] || fail "entry not in trash"
|
||
pass "post-trash moves to trash"
|
||
|
||
# 9. Menus
|
||
MENU=$(ability lit/menu-create '{"name":"LIT Smoke Menü"}')
|
||
MENU_ID=$(echo "$MENU" | jq -r '.menu_id // empty')
|
||
[ -n "$MENU_ID" ] || fail "menu-create: $MENU"
|
||
ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"items\":[{\"type\":\"post\",\"object_id\":$PAGE_ID,\"children\":[{\"type\":\"custom\",\"url\":\"/kontakt\",\"title\":\"Kontakt\"}]}]}" \
|
||
| jq -e '.ok == true' >/dev/null || fail "menu-set-items (tree)"
|
||
TREE=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}")
|
||
CHILD_ID=$(echo "$TREE" | jq -r '.items[0].children[0].id // empty')
|
||
[ "$(echo "$TREE" | jq -r '.items[0].children[0].title')" = "Kontakt" ] || fail "menu tree: $TREE"
|
||
REMOVE=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}]}")
|
||
[ "$(error_code "$REMOVE")" = "confirm_required" ] || fail "menu removal without confirm: $REMOVE"
|
||
ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}],\"confirm\":true}" \
|
||
| jq -e '(.previous_items[0].children | length) == 1 and (.items[0].children | length) == 0' >/dev/null \
|
||
|| fail "menu removal with confirm"
|
||
LOCATION=$(ability lit/menu-list '{}' | jq -r '.locations[0].location // empty')
|
||
if [ -n "$LOCATION" ]; then
|
||
ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":$MENU_ID}" | jq -e '.ok == true' >/dev/null \
|
||
|| fail "menu-assign-location"
|
||
fi
|
||
pass "menu create, tree, operations with confirm, location"
|
||
|
||
# 10. WordPress settings (whitelist)
|
||
ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname")' >/dev/null \
|
||
|| fail "wp-settings-get"
|
||
BLOCKED=$(ability lit/wp-settings-update '{"settings":{"admin_email":"x@example.invalid"}}')
|
||
[ "$(error_code "$BLOCKED")" = "validation_failed" ] || fail "blocked setting accepted: $BLOCKED"
|
||
ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT Smoke Test"}}' \
|
||
| jq -e '.previous.blogdescription != null' >/dev/null || fail "wp-settings-update"
|
||
[ "$(wp option get blogdescription)" = "LIT Smoke Test" ] || fail "blogdescription not updated"
|
||
pass "WordPress settings whitelist"
|
||
|
||
# 11. Change log with client name
|
||
PREFIX=$(wp db prefix)
|
||
ROWS=$(wp db query "SELECT COUNT(*) FROM ${PREFIX}lit_mcp_log WHERE ability = 'lit/content-create' AND client = 'lit-mcp-integration-test' AND created_at > (UTC_TIMESTAMP() - INTERVAL 10 MINUTE)" --skip-column-names)
|
||
[ "$ROWS" -ge 2 ] || fail "change log rows missing (found $ROWS)"
|
||
pass "change log records ability and client"
|
||
|
||
echo "ALL CHECKS PASSED"
|
||
```
|
||
|
||
- [ ] **Step 4: Ende-zu-Ende-Test ausführen**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh
|
||
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
|
||
timeout 120 ddev wp plugin is-active pods || echo "pods inactive (restored)"
|
||
```
|
||
Expected: beide Skripte `ALL CHECKS PASSED`; Pods danach wieder inaktiv. Schlägt ein Schritt mit `ability_invalid_output` fehl, passt die Ausgabe nicht zum `outputSchema` – Schema oder Ausgabe korrigieren und einen Unit-Test für den Fall ergänzen.
|
||
|
||
- [ ] **Step 5: Unit-Tests und Lint**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add docs/guides tests/Integration/lib.sh tests/Integration/wp-basis-smoke.sh
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: end-to-end checks for the WordPress basics; content and workflow guides
|
||
|
||
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (durchgeführt beim Schreiben)
|
||
|
||
- **Spec-Abdeckung (Teil 1a):** §6.1 Schema inkl. Pods → Tasks 4–6; §6.2 Content-Tools, Status, Kopie/Austausch, Papierkorb, Revisionen, `lit_mcp_after_save` → Tasks 8–12; §6.4 Menüs → Tasks 13–14; §6.5 Einstellungen → Task 15; §8.1 ToolResult → Task 1; §8.2 Fehler mit hint, alles oder nichts, Stapel → Tasks 1, 7, 9; §8.3 Protokolltabelle, Aufbewahrung, Cron → Task 3 (Ansicht/Leeren in 1b); §9.1 Instructions mit Inhaltstypen → Task 6; §9.2 Guides `content-types`, `workflows` → Task 16; §11.3 Schemas, Annotations, Cursor, deterministische Tool-Reihenfolge → alle Ability-Tasks, `WordPressIntegrationTest`; §11.4 Output-Schema-Prüfung je Ability → Task 16 (WordPress validiert jede Ausgabe). Etappe-0-Rückstände: (e) Flag-Reset → Task 2, (f) `Tested up to` → Task 1. Medien §6.3, Einstellungsseite §8.4, Expositions-Policy §4.3, Sichtbarkeit, Hinweise, Aufräumen, Doku → Plan 1b.
|
||
- **Bewusste Abweichungen/Präzisierungen:** Divi-Kurzform in `lit-content-create` (§6.2) kommt mit Etappe 2 (BlockTree fehlt noch); bis dahin nur Hinweis. `rev` ist für Nicht-Divi-Inhalte optional und nur in `lit-content-update` geprüft. Fehler-JSON in der Nachricht, weil Adapter 0.5 nur den Text weitergibt. Menüeinträge werden wie im WordPress-Menü-Editor endgültig entfernt (kein Papierkorb für `nav_menu_item`), aber nur mit `confirm: true` und mit `previous_items`.
|
||
- **Typ-Konsistenz geprüft:** `PostGuard::editable/postType/id`, `RevisionPoint::capture/latest`, `ChangeLog::record(…, ?int $revisionId)`, `ToolResult::with()`-Extras (`original_id`, `menu_id`, `previous_menu_id`, `items`, `previous_items`, `previous`) stimmen zwischen Interfaces-Blöcken, Code und Tests überein.
|