Files
WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh
T
janniclas.loosenandClaude Opus 5.5 251934ce53 feat: deferred 1a items - menu_rev lock, lit_mcp_guides filter, warnings, schedule guard precision
Task 14 (rulings P17/P18, Q10):
- lit/menu-get returns menu_rev (Rev::ofMenu over id, parent, position,
  title, url, object, object_id); lit/menu-set-items requires it, refuses
  a stale one with rev_mismatch (hint: lit/menu-get) and returns the new
  menu_rev. Errors::revMismatch takes an optional message/hint.
- GuideRepository: filter lit_mcp_guides adds/overrides topics; invalid
  topic names and non-string content are ignored, files stay the default.
- lit/post-trash names front page, posts page and linking menus in the
  confirmation and as warnings; lit/menu-assign-location needs confirm
  to remove an assigned menu and names it; lit/post-schedule warns that
  a private entry becomes public; Divi hints no longer point to
  non-existing lit-divi-* tools.
- PostGuard::missedSchedule: 60 s publish window, zero GMT date falls
  back to get_gmt_from_date(post_date); post-schedule refuses dates
  within that window.
- write_failed after a clean rollback that keeps a revision/backup says
  "Die Aenderung wurde nicht gespeichert (... bleibt erhalten)" via the
  new state parameter (Errors::writeFailed / WriteFailed rolledBackState).
- wp-basis-smoke: menu_rev flow, unassign confirm, trash/menu and
  private-schedule warnings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 04:33:15 +02:00

227 lines
16 KiB
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_BLOGNAME=$(wp eval 'echo get_option("blogname");')
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
# Restore the stored (already escaped) form byte-exact, bypassing sanitize_option().
LIT_BLOGNAME="$ORIGINAL_BLOGNAME" wp eval \
'global $wpdb; $wpdb->update($wpdb->options, ["option_value" => getenv("LIT_BLOGNAME")], ["option_name" => "blogname"]); wp_cache_delete("alloptions", "options"); wp_cache_delete("blogname", "options");' \
>/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"
PRIVATE=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke Privat"}')
PRIVATE_ID=$(echo "$PRIVATE" | jq -r '.changed[0].id // empty')
[ -n "$PRIVATE_ID" ] || fail "content-create (private): $PRIVATE"
CREATED_POSTS+=("$PRIVATE_ID")
wp post update "$PRIVATE_ID" --post_status=private >/dev/null
SCHEDULED_PRIVATE=$(ability lit/post-schedule "{\"post_id\":$PRIVATE_ID,\"date\":\"$FUTURE\"}")
echo "$SCHEDULED_PRIVATE" | jq -e '.ok == true and (.warnings | any(contains("ÖFFENTLICH")))' >/dev/null \
|| fail "scheduling a private page does not warn: $SCHEDULED_PRIVATE"
pass "publish/unpublish/schedule, past dates rejected, private schedule warns"
# 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"
PERSON_REV=$(tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -r '.rev')
TRASHED=$(tool lit-content-update "{\"post_id\":$PERSON_ID,\"rev\":\"$PERSON_REV\",\"title\":\"x\"}")
[ "$(error_code "$TRASHED")" = "validation_failed" ] || fail "update of a trashed entry accepted: $TRASHED"
pass "post-trash moves to trash; trashed entries are not updated"
# 8b. A missed schedule is never published as a side effect of a write
MISSED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke verpasst"}')
MISSED_ID=$(echo "$MISSED" | jq -r '.changed[0].id // empty')
[ -n "$MISSED_ID" ] || fail "content-create: $MISSED"
CREATED_POSTS+=("$MISSED_ID")
LIT_ID="$MISSED_ID" wp eval \
'global $wpdb; $wpdb->update($wpdb->posts, ["post_status" => "future", "post_date" => "2020-01-01 10:00:00", "post_date_gmt" => "2020-01-01 09:00:00"], ["ID" => (int) getenv("LIT_ID")]); clean_post_cache((int) getenv("LIT_ID"));' \
>/dev/null
MISSED_REV=$(tool lit-content-get "{\"post_id\":$MISSED_ID}" | jq -r '.rev')
REFUSED=$(tool lit-content-update "{\"post_id\":$MISSED_ID,\"rev\":\"$MISSED_REV\",\"title\":\"x\"}")
[ "$(error_code "$REFUSED")" = "validation_failed" ] || fail "missed schedule updated: $REFUSED"
[ "$(wp post get "$MISSED_ID" --field=post_status)" = "future" ] || fail "missed schedule got published"
pass "content-update refuses a missed schedule instead of publishing it"
# 9. Menus (lit/menu-set-items: menu_rev from lit/menu-get required)
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"
MENU_REV=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}" | jq -r '.menu_rev // empty')
[ -n "$MENU_REV" ] || fail "menu-get lacks menu_rev"
NOREV=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"000000000000\",\"items\":[]}")
[ "$(error_code "$NOREV")" = "rev_mismatch" ] || fail "stale menu_rev accepted: $NOREV"
SET=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"$MENU_REV\",\"items\":[{\"type\":\"post\",\"object_id\":$PAGE_ID,\"children\":[{\"type\":\"custom\",\"url\":\"/kontakt\",\"title\":\"Kontakt\"}]}]}")
echo "$SET" | jq -e '.ok == true and (.menu_rev | type) == "string"' >/dev/null || fail "menu-set-items (tree): $SET"
STALE_MENU=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"$MENU_REV\",\"items\":[]}")
[ "$(error_code "$STALE_MENU")" = "rev_mismatch" ] || fail "outdated menu_rev accepted: $STALE_MENU"
TREE=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}")
[ "$(echo "$TREE" | jq -r '.menu_rev')" = "$(echo "$SET" | jq -r '.menu_rev')" ] || fail "menu_rev of set-items differs from menu-get"
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=$(echo "$TREE" | jq -c --argjson id "$MENU_ID" '{menu_id: $id, menu_rev: .menu_rev, 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"
MENU_REV=$(echo "$UNCHANGED" | jq -r '.menu_rev')
REMOVE=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"$MENU_REV\",\"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,\"menu_rev\":\"$MENU_REV\",\"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"
UNASSIGN=$(ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":0}")
[ "$(error_code "$UNASSIGN")" = "confirm_required" ] || fail "unassign without confirm: $UNASSIGN"
ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":0,\"confirm\":true}" \
| jq -e '.summary | contains("LIT Smoke Menü")' >/dev/null || fail "unassign with confirm does not name the menu"
fi
TRASH_LINKED=$(ability lit/post-trash "{\"post_id\":$PAGE_ID,\"confirm\":true}")
echo "$TRASH_LINKED" | jq -e '.changed[0].action == "trashed" and (.warnings | any(contains("LIT Smoke Menü")))' >/dev/null \
|| fail "trashing a menu-linked page does not name the menu: $TRASH_LINKED"
pass "menu create, menu_rev lock, tree, no-op resend, operations with confirm, location (unassign with confirm), trash warns about menu link"
# 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"
AMP='{"settings":{"blogname":"LIT Müller & Söhne","blogdescription":"LIT Smoke & Test"}}'
ability lit/wp-settings-update "$AMP" | jq -e '.ok == true' >/dev/null || fail "settings with & (first call)"
AGAIN=$(ability lit/wp-settings-update "$AMP")
echo "$AGAIN" | jq -e '.ok == true and (.changed | length) == 0 and .previous.blogname == "LIT Müller & Söhne"' >/dev/null \
|| fail "settings with & (second call): $AGAIN"
ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname") | .value == "LIT Müller & Söhne"' >/dev/null \
|| fail "wp-settings-get does not return blogname as typed"
pass "WordPress settings whitelist; text with & can be set twice and reads back as typed"
# 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"