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
This commit is contained in:
@@ -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": "<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 // "")'
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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":"<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 (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\":\"<p>Aus Kopie</p>\"}" >/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)" = "<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"
|
||||
[ "$(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"
|
||||
Reference in New Issue
Block a user