From 46a445fe9d34952822d4a7e80fd9de75c95d222e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:01:29 +0200 Subject: [PATCH] test: end-to-end checks for the WordPress basics; content and workflow guides Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/content-types.md | 66 +++++++++++ docs/guides/overview.md | 20 +++- docs/guides/workflows.md | 37 ++++++ tests/Integration/lib.sh | 117 +++++++++++++++++++ tests/Integration/mcp-smoke.sh | 57 ++------- tests/Integration/wp-basis-smoke.sh | 174 ++++++++++++++++++++++++++++ 6 files changed, 422 insertions(+), 49 deletions(-) create mode 100644 docs/guides/content-types.md create mode 100644 docs/guides/workflows.md create mode 100644 tests/Integration/lib.sh create mode 100644 tests/Integration/wp-basis-smoke.sh diff --git a/docs/guides/content-types.md b/docs/guides/content-types.md new file mode 100644 index 0000000..da6fa91 --- /dev/null +++ b/docs/guides/content-types.md @@ -0,0 +1,66 @@ +# 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`. `field` und `taxonomy` sind auf Feld- bzw. Taxonomienamen beschränkt, die + `lit-content-types` für diesen Inhaltstyp auch anzeigt – ein unbekannter Name wird abgelehnt, keine leere + Trefferliste. Geliefert werden nur Einträge, die der verbundene Nutzer selbst bearbeiten darf (fremde + Entwürfe/private Inhalte ohne die passende Berechtigung erscheinen nicht). +- `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.` im Fehler nennt die Ursache). +- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist + **Pflicht** – hat jemand parallel gespeichert (Inhalt, Titel, Auszug, Status oder irgendetwas anderes am + Eintrag), kommt `rev_mismatch`: neu lesen, Änderung auf dem aktuellen Stand 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; die Antwort enthält die `rev` der Kopie für `lit-content-update`. +2. Kopie mit `lit-content-update` (oder den Divi-Tools) bearbeiten und dem Nutzer die Vorschau zeigen. +3. `lit-post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). + Voraussetzungen: die Kopie muss noch Entwurf oder ausstehend sein, `rev` des Originals aus + `lit-content-get` ist Pflicht (sie deckt Titel, Inhalt, Auszug und Status ab – Felder/Meta, die direkt am + Original geändert wurden, werden dabei überschrieben), und erst nach Rückfrage mit `confirm: true` + aufrufen. Vorher entsteht eine Revision **und** eine vollständige Sicherung des bisherigen Originals im + Papierkorb (`backup_id` in der Antwort); danach wandert die Kopie ebenfalls in den Papierkorb. Ist der + Papierkorb der Website deaktiviert, wird der Austausch verweigert, statt die Kopie endgültig zu löschen. + +Jede Änderung liefert `revision_id` (Stand vorher) und `log_id`. Rückgängig: `lit-revisions-list` → +`lit-revision-restore` mit der `rev` des Eintrags (aus `lit-content-get`) und `confirm: true`. Revisionen +umfassen Titel, Inhalt und Auszug – Felder und Begriffe nicht. + +## Wenn ein Schreibvorgang fehlschlägt + +Schlägt ein Schreibvorgang nach ersten Änderungen fehl, meldet das Tool `lit_write_failed` (alles wurde +zurückgenommen, nichts wurde gespeichert) oder `lit_partial_write` (die Rücknahme war unvollständig). Beide +Fehler stecken als JSON (`code`, `message`, `hint`, ggf. weitere Felder) in der Fehlermeldung. Bei +`lit_partial_write` vor einem erneuten Versuch immer erst den aktuellen Stand prüfen (`lit-content-get`, +`lit-menu-get` o. Ä.) statt den Aufruf einfach zu wiederholen – dem `hint` folgen. diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 6f8cdd6..2f40e7b 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -15,9 +15,21 @@ des verbundenen WordPress-Benutzers. 4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools verlangen den `rev` des letzten Lesevorgangs. -## Verfügbare Werkzeuge (Etappe 0) +## Werkzeuge -- `lit-guide` – diese Anleitungen. Weitere Themen: siehe Feld `topics`. -- `lit-site-status` – Überblick über Website, aktive Integrationen und eigene Rechte. +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`. -Weitere Werkzeuge kommen mit den nächsten Ausbaustufen hinzu. +Ü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. diff --git a/docs/guides/workflows.md b/docs/guides/workflows.md new file mode 100644 index 0000000..8b06d5e --- /dev/null +++ b/docs/guides/workflows.md @@ -0,0 +1,37 @@ +# 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": , "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`). Wird der Baum unverändert + zurückgeschickt, meldet das Tool „keine Änderung“ und schreibt nichts. +- Für kleine Änderungen `operations`: `{op: "add", item, parent_id?, position?}`, + `{op: "move", id, parent_id?, position?}`, `{op: "remove", id}` (Kinder eines entfernten Eintrags rücken + an seine Stelle). +- Neue Einträge ohne `id`: `type` `post`/`taxonomy` mit `object_id`, oder `custom` mit `url` (`/pfad`, + `#anker`, `http(s)`, `mailto`, `tel`) und `title`; jeweils optional `children`. Das Ziel eines bestehenden + Eintrags lässt sich nicht ändern – dafür entfernen und neu anlegen. +- Verlinkt werden nur für den Nutzer lesbare, veröffentlichte Inhalte und Begriffe öffentlicher Taxonomien. + Private Inhalte sind immer ausgeschlossen. Entwürfe, ausstehende oder geplante Inhalte lassen sich nur mit + `allow_unpublished: true` verlinken – WordPress zeigt den Menüpunkt sofort an, Besucher bekommen bis zur + Veröffentlichung einen toten Link (die Antwort enthält dazu eine Warnung). +- Fallen dabei Einträge weg, verlangt das Tool `confirm: true`; `previous_items` (Stand vorher) erlaubt das + Zurückbauen – neu angelegte Einträge erhalten dabei eine neue `id`, CSS-Klassen, Linkziel und Beschreibung + gehen verloren. + +## 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). diff --git a/tests/Integration/lib.sh b/tests/Integration/lib.sh new file mode 100644 index 0000000..ab64ba7 --- /dev/null +++ b/tests/Integration/lib.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Shared helpers for the MCP integration scripts (mcp-smoke.sh, wp-basis-smoke.sh, +# and Etappe 1b's smoke scripts). Source it from a script that runs INSIDE the DDEV +# web container with /var/www/html as its working directory (cd there before +# sourcing – HOST below shells out to `wp` immediately). +# 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 + +# The site's own Host header (without scheme). A request that bypasses normal WP +# routing (e.g. the raw discovery rewrite target mcp-smoke.sh probes) needs this +# Host header, or WordPress 301s it to the canonical host before it ever reaches +# the code under test. +HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##') + +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 an MCP access token for a user, exactly like mcp-oauth's TokenEndpoint does +# after a completed OAuth flow (mint-token.php). Also creates the throwaway +# "lit-mcp-integration-test" application password cleanup_app_passwords_for removes. +mint_token() { # $1 = user_login -> prints the token + wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$1" +} + +# Raw JSON-RPC POST to the MCP endpoint; prints headers+body (-i). $3 (session id) +# is optional. Low-level building block: used directly where a script needs the +# response headers (e.g. to read Mcp-Session-Id off the initialize response) and by +# rpc() below for everything after that. +mcp() { # $1 = token, $2 = json body, $3 = session id (optional) + local session_header=() + [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") + curl -s --max-time 20 -i -X POST "$ENDPOINT" \ + -H "Authorization: Bearer $1" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + "${session_header[@]}" -d "$2" +} + +# 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=$(mint_token "$1") + local init + init=$(mcp "$TOKEN" '{"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" +} + +# JSON-RPC call over the session connect() opened; prints the response body only. +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": ""} 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 // "")' +} diff --git a/tests/Integration/mcp-smoke.sh b/tests/Integration/mcp-smoke.sh index 3b1c4af..684d2b3 100644 --- a/tests/Integration/mcp-smoke.sh +++ b/tests/Integration/mcp-smoke.sh @@ -3,13 +3,9 @@ # Runs INSIDE the DDEV web container (wp-cli and curl available there): # cd /home/janniclas/Projekte/WordPress && ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh set -euo pipefail - -PLUGIN=wp-content/plugins/WP-LIT-MCP -ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server cd /var/www/html - -fail() { echo "FAIL: $*"; exit 1; } -pass() { echo "ok - $*"; } +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh # --- Cleanup: remove every "lit-mcp-integration-test" application password # this script mints (for every user it mints one for) and the throwaway @@ -17,23 +13,6 @@ pass() { echo "ok - $*"; } # (success, fail(), or an unexpected error under `set -e`). ADMIN_LOGIN="" -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 -} - cleanup() { cleanup_app_passwords_for "$ADMIN_LOGIN" cleanup_app_passwords_for lit-smoke-sub @@ -41,16 +20,6 @@ cleanup() { } trap cleanup EXIT -mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+body - local session_header=() - [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") - curl -s --max-time 20 -i -X POST "$ENDPOINT" \ - -H "Authorization: Bearer $1" \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - "${session_header[@]}" -d "$2" -} - # 0. Booting REST triggers no _doing_it_wrong(). A regression in the # unhook-before-create ordering (ServerRegistrar::createServer()) would # otherwise surface as a "duplicate server id" _doing_it_wrong() notice when @@ -73,14 +42,12 @@ pass "booting REST triggers no _doing_it_wrong()" # # The HARD checks below instead hit mcp-oauth's own rewrite target directly # (index.php?mcp_oauth_discovery=...) via a request whose Host matches the -# site's home URL (WordPress issues a 301 to the canonical host otherwise), -# proving the discovery documents themselves are correct. The ENVIRONMENT -# check further down separately probes the real `/.well-known/` path and only -# warns, never fails, so a misconfigured web server doesn't block this suite -# while still surfacing the risk (real MCP clients like claude.ai use the -# real path, not the rewrite target). -HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##') - +# site's home URL (WordPress issues a 301 to the canonical host otherwise; +# HOST comes from lib.sh), proving the discovery documents themselves are +# correct. The ENVIRONMENT check further down separately probes the real +# `/.well-known/` path and only warns, never fails, so a misconfigured web +# server doesn't block this suite while still surfacing the risk (real MCP +# clients like claude.ai use the real path, not the rewrite target). curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ 'http://localhost/?mcp_oauth_discovery=protected-resource' \ | grep -q '"resource"' \ @@ -110,7 +77,7 @@ pass "401 + WWW-Authenticate without token" # 3. Administrator: initialize, tools/list, tools/call ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) -TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$ADMIN_LOGIN") +TOKEN=$(mint_token "$ADMIN_LOGIN") INIT=$(mcp "$TOKEN" '{"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) echo "$INIT" | grep -q 'LIT MCP pflegt' || fail "instructions missing in initialize result" @@ -130,8 +97,8 @@ pass "tools/call lit-site-status works" # 3b. mcp-adapter's default (unprotected) server must not exist: the route # it would register, mcp-adapter-default-server, must 404. Uses a temporary # application password (Basic auth) instead of the MCP bearer token, since -# this checks a plain REST route, not the MCP endpoint. Named like the mint- -# token.php passwords so the cleanup trap above also removes it. +# this checks a plain REST route, not the MCP endpoint. Named like mint_token's +# passwords so the cleanup trap above also removes it. APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain) DEFAULT_SERVER_STATUS=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' \ -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ @@ -150,7 +117,7 @@ pass "MCP token is rejected on /wp/v2/users/me" # 5. Role gate: subscriber has a valid token but must not use tools wp user create lit-smoke-sub lit-smoke-sub@example.invalid --role=subscriber >/dev/null -SUB_TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" lit-smoke-sub) +SUB_TOKEN=$(mint_token lit-smoke-sub) SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION") diff --git a/tests/Integration/wp-basis-smoke.sh b/tests/Integration/wp-basis-smoke.sh new file mode 100644 index 0000000..4da025e --- /dev/null +++ b/tests/Integration/wp-basis-smoke.sh @@ -0,0 +1,174 @@ +#!/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":"

Hallo

"}') +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\":\"

Neu

\"}") +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 (lit-content-update, lit/post-replace-with-copy: rev required) +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") +COPY_REV=$(echo "$COPY" | jq -r '.rev') +tool lit-content-update "{\"post_id\":$COPY_ID,\"rev\":\"$COPY_REV\",\"content\":\"

Aus Kopie

\"}" >/dev/null +ORIGINAL_REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') +NOCONFIRM=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"rev\":\"$ORIGINAL_REV\"}") +[ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "replace without confirm: $NOCONFIRM" +REPLACED=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"rev\":\"$ORIGINAL_REV\",\"confirm\":true}") +echo "$REPLACED" | jq -e '.revision_id != null and .backup_id != null' >/dev/null || fail "replace-with-copy: $REPLACED" +BACKUP_ID=$(echo "$REPLACED" | jq -r '.backup_id') +CREATED_POSTS+=("$BACKUP_ID") +[ "$(wp post get "$PAGE_ID" --field=post_content)" = "

Aus Kopie

" ] || 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" +[ "$(wp post get "$BACKUP_ID" --field=post_status)" = "trash" ] || fail "backup of the original not trashed" +pass "duplicate and replace-with-copy (status kept, copy trashed, backup taken)" + +# 7. Revisions (lit/revision-restore: rev required) +REVISIONS=$(ability lit/revisions-list "{\"post_id\":$PAGE_ID}") +OLDEST=$(echo "$REVISIONS" | jq -r '.items[-1].id // empty') +[ -n "$OLDEST" ] || fail "revisions-list: $REVISIONS" +PAGE_REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') +ability lit/revision-restore "{\"revision_id\":$OLDEST,\"rev\":\"$PAGE_REV\",\"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" +UNCHANGED_ARGS=$(jq -cn --argjson id "$MENU_ID" --argjson items "$(echo "$TREE" | jq -c '.items')" '{menu_id: $id, items: $items}') +UNCHANGED=$(ability lit/menu-set-items "$UNCHANGED_ARGS") +echo "$UNCHANGED" | jq -e '.summary | contains("keine Änderung")' >/dev/null || fail "menu no-op: $UNCHANGED" +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, no-op resend, 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"