Files
mcphub/tests/plugins/wordpress_specialist/test_pages.py
airano-ir 43fd2201a0 feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes:
- MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings
  table (DB > ENV > default), so dashboard/settings changes apply without
  restart. Sync cache refreshed on every save or delete.
- /api/me reports the live DB value for max_sites_per_user.

Admin improvements:
- Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS).
- Admin Overview now shows platform stats: registered users, new users (7d),
  total user sites, available tools.

Plugin cleanup:
- Appwrite and Directus plugins removed from the active registry (8 plugins
  now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase,
  OpenPanel, Coolify). Plugin code is retained for future re-enabling.
- Settings page plugin visibility list updated to match.

Mobile onboarding:
- Stepper steps on narrow viewports stack vertically with correct full border
  and rounded corners on each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:33:20 +02:00

415 lines
15 KiB
Python

"""F.19.5 — Tests for the WordPress Specialist page-editing handler.
Mocks ``WordPressClient.get`` and ``WordPressClient.post`` and asserts:
* tool spec contract: 11 tools in three buckets (4 Gutenberg + 6 Elementor
+ 1 Classic), with reads at ``scope=read`` and writes at ``scope=editor``
* each handler method targets the correct ``airano-mcp/v1/admin/*``
route (or stock ``wp/v2/{type}/{id}``) with ``use_custom_namespace``
set correctly
* client-side guards reject obviously-bad input (negative post_id,
oversized block array, oversized Elementor tree) before the wire
* ``wp_blocks_get`` runs the block parser server-side in MCPHub —
no companion route is consulted on reads
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.pages import (
PagesHandler,
_count_elementor_nodes,
_parse_blocks_python,
get_tool_specifications,
)
# ───── Tool spec contract ────────────────────────────────────────────
def test_tool_specs_count_and_names_match_f195():
specs = get_tool_specifications()
assert len(specs) == 11, "F.19.5 advertises 4 Gutenberg + 6 Elementor + 1 Classic"
names = {s["name"] for s in specs}
assert names == {
# Gutenberg
"wp_blocks_get",
"wp_blocks_replace",
"wp_blocks_insert_at",
"wp_blocks_remove_at",
# Elementor
"wp_elementor_detect",
"wp_elementor_get",
"wp_elementor_set",
"wp_elementor_render_css",
"wp_elementor_template_list",
"wp_elementor_template_apply",
# Classic
"wp_classic_html_replace",
}
def test_reads_are_read_scope_writes_are_editor_scope():
specs_by_name = {s["name"]: s for s in get_tool_specifications()}
expected_scope = {
"wp_blocks_get": "read",
"wp_blocks_replace": "editor",
"wp_blocks_insert_at": "editor",
"wp_blocks_remove_at": "editor",
"wp_elementor_detect": "read",
"wp_elementor_get": "read",
"wp_elementor_set": "editor",
"wp_elementor_render_css": "editor",
"wp_elementor_template_list": "read",
"wp_elementor_template_apply": "editor",
"wp_classic_html_replace": "editor",
}
for name, scope in expected_scope.items():
assert specs_by_name[name]["scope"] == scope, f"{name} should be scope={scope}"
def test_every_spec_has_the_full_contract():
"""Each F.19.5 spec must carry name, method_name, description, schema."""
for spec in get_tool_specifications():
assert spec["name"] == spec["method_name"]
assert spec["description"]
assert isinstance(spec["schema"], dict)
assert spec["schema"].get("type") == "object"
def test_blocks_replace_schema_caps_at_200():
spec = next(s for s in get_tool_specifications() if s["name"] == "wp_blocks_replace")
assert spec["schema"]["properties"]["blocks"]["maxItems"] == 200
# ───── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.get = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.post = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return PagesHandler(client)
# ───── Gutenberg routing ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wp_blocks_get_reads_via_stock_rest_no_companion(handler, client):
"""Reads MUST go through stock REST so non-companion sites work."""
client.get = AsyncMock( # type: ignore[method-assign]
return_value={
"id": 42,
"content": {
"raw": ("<!-- wp:paragraph -->\n<p>Hello world.</p>\n<!-- /wp:paragraph -->"),
"rendered": "<p>Hello world.</p>",
},
}
)
handler.client = client
result = await handler.wp_blocks_get(post_id=42)
client.get.assert_awaited_once_with("posts/42", params={"context": "edit"})
assert result["post_id"] == 42
assert result["count"] == 1
assert result["blocks"][0]["blockName"] == "core/paragraph"
@pytest.mark.asyncio
async def test_wp_blocks_get_supports_pages_collection(handler, client):
client.get = AsyncMock(return_value={"content": {"raw": ""}}) # type: ignore[method-assign]
handler.client = client
await handler.wp_blocks_get(post_id=12, post_type="pages")
client.get.assert_awaited_once_with("pages/12", params={"context": "edit"})
@pytest.mark.asyncio
async def test_wp_blocks_get_rejects_bogus_post_type(handler):
with pytest.raises(ValueError, match="post_type"):
await handler.wp_blocks_get(post_id=1, post_type="products")
@pytest.mark.asyncio
async def test_wp_blocks_replace_calls_companion_route(handler, client):
blocks = [
{
"blockName": "core/paragraph",
"attrs": {},
"innerBlocks": [],
"innerHTML": "<p>hi</p>",
"innerContent": ["<p>hi</p>"],
}
]
await handler.wp_blocks_replace(post_id=7, blocks=blocks)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/blocks/replace",
json_data={"post_id": 7, "blocks": blocks, "raw_html": False},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_blocks_replace_rejects_oversized_payload(handler, client):
too_many = [{"blockName": "core/paragraph"} for _ in range(201)]
with pytest.raises(ValueError, match="exceeds 200"):
await handler.wp_blocks_replace(post_id=1, blocks=too_many)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_wp_blocks_replace_passes_raw_html_flag_when_true(handler, client):
await handler.wp_blocks_replace(post_id=1, blocks=[], raw_html=True)
args, kwargs = client.post.call_args
assert kwargs["json_data"]["raw_html"] is True
@pytest.mark.asyncio
async def test_wp_blocks_insert_at_passes_index(handler, client):
await handler.wp_blocks_insert_at(post_id=3, block={"blockName": "core/paragraph"}, index=2)
args, kwargs = client.post.call_args
assert args[0] == "airano-mcp/v1/admin/blocks/insert"
assert kwargs["json_data"]["index"] == 2
@pytest.mark.asyncio
async def test_wp_blocks_insert_at_omits_index_when_unset(handler, client):
await handler.wp_blocks_insert_at(post_id=3, block={"blockName": "core/paragraph"})
args, kwargs = client.post.call_args
assert "index" not in kwargs["json_data"], "missing index → companion appends"
@pytest.mark.asyncio
async def test_wp_blocks_remove_at_calls_remove_route(handler, client):
await handler.wp_blocks_remove_at(post_id=3, index=1)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/blocks/remove",
json_data={"post_id": 3, "index": 1},
use_custom_namespace=True,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_id", [0, -1, "1", 1.5, True])
async def test_block_writes_reject_bad_post_id(handler, client, bad_id):
with pytest.raises(ValueError):
await handler.wp_blocks_replace(post_id=bad_id, blocks=[])
client.post.assert_not_awaited()
# ───── Elementor routing ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wp_elementor_detect_calls_status_route(handler, client):
await handler.wp_elementor_detect()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/status", use_custom_namespace=True
)
@pytest.mark.asyncio
async def test_wp_elementor_get_calls_per_post_route(handler, client):
await handler.wp_elementor_get(post_id=99)
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/99", use_custom_namespace=True
)
@pytest.mark.asyncio
async def test_wp_elementor_set_posts_data_array(handler, client):
data = [{"id": "abc", "elType": "section", "settings": {}, "elements": []}]
await handler.wp_elementor_set(post_id=99, data=data)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/99",
json_data={"data": data},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_elementor_set_rejects_oversized_tree_before_wire(handler, client):
"""S-14 — the companion enforces 5,000 nodes; mirror in MCPHub."""
def _make_tree(width: int):
return [
{"id": str(i), "elType": "section", "settings": {}, "elements": []}
for i in range(width)
]
too_many = _make_tree(5001)
with pytest.raises(ValueError, match="exceeds 5000"):
await handler.wp_elementor_set(post_id=1, data=too_many)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_wp_elementor_render_css_calls_regen_route(handler, client):
await handler.wp_elementor_render_css(post_id=12)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/12/regen-css",
json_data={},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_elementor_template_list_calls_templates_route(handler, client):
await handler.wp_elementor_template_list()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/templates", use_custom_namespace=True
)
@pytest.mark.asyncio
async def test_wp_elementor_template_apply_passes_both_ids(handler, client):
await handler.wp_elementor_template_apply(template_id=1, post_id=2)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/templates/apply",
json_data={"template_id": 1, "post_id": 2},
use_custom_namespace=True,
)
# ───── Classic routing ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wp_classic_html_replace_calls_classic_route(handler, client):
await handler.wp_classic_html_replace(post_id=5, html="<p>x</p>")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/classic/5/replace",
json_data={"html": "<p>x</p>", "raw_html": False},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_classic_html_replace_rejects_non_string_html(handler, client):
with pytest.raises(ValueError, match="html must be a string"):
await handler.wp_classic_html_replace(post_id=5, html=42) # type: ignore[arg-type]
client.post.assert_not_awaited()
# ───── Block parser ──────────────────────────────────────────────────
def test_parse_blocks_handles_empty_string():
assert _parse_blocks_python("") == []
def test_parse_blocks_returns_freeform_for_classic_html():
blocks = _parse_blocks_python("<p>Pre-block-editor content</p>")
assert len(blocks) == 1
assert blocks[0]["blockName"] is None
assert blocks[0]["innerHTML"] == "<p>Pre-block-editor content</p>"
def test_parse_blocks_extracts_top_level_paragraph():
html = "<!-- wp:paragraph -->\n" "<p>Hello world.</p>\n" "<!-- /wp:paragraph -->"
blocks = _parse_blocks_python(html)
assert len(blocks) == 1
assert blocks[0]["blockName"] == "core/paragraph"
assert "Hello world" in blocks[0]["innerHTML"]
def test_parse_blocks_decodes_attributes_json():
html = '<!-- wp:heading {"level":3} -->\n' "<h3>Title</h3>\n" "<!-- /wp:heading -->"
blocks = _parse_blocks_python(html)
assert blocks[0]["attrs"] == {"level": 3}
def test_parse_blocks_handles_nested_blocks():
html = (
"<!-- wp:group -->\n"
'<div class="wp-block-group">\n'
"<!-- wp:paragraph -->\n"
"<p>Inner.</p>\n"
"<!-- /wp:paragraph -->\n"
"</div>\n"
"<!-- /wp:group -->"
)
blocks = _parse_blocks_python(html)
assert len(blocks) == 1
assert blocks[0]["blockName"] == "core/group"
assert len(blocks[0]["innerBlocks"]) == 1
assert blocks[0]["innerBlocks"][0]["blockName"] == "core/paragraph"
def test_parse_blocks_handles_self_closing_block():
html = "<!-- wp:separator /-->"
blocks = _parse_blocks_python(html)
assert len(blocks) == 1
assert blocks[0]["blockName"] == "core/separator"
assert blocks[0]["innerBlocks"] == []
# ───── Node counter ──────────────────────────────────────────────────
def test_count_elementor_nodes_walks_recursively():
tree = [
{
"id": "a",
"elType": "section",
"settings": {},
"elements": [
{
"id": "b",
"elType": "column",
"settings": {},
"elements": [{"id": "c", "elType": "widget", "settings": {}}],
}
],
},
{"id": "d", "elType": "section", "settings": {}, "elements": []},
]
assert _count_elementor_nodes(tree) == 4
# ───── Plugin-level wiring ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_plugin_returns_combined_specs_count():
"""The wordpress_specialist plugin must merge management + pages."""
from plugins.wordpress_specialist import WordPressSpecialistPlugin
specs = WordPressSpecialistPlugin.get_tool_specifications()
assert len(specs) == 51, (
"9 management + 11 page + 7 theme + 6 plugin-write + "
"6 site-config + 7 site-layout + 3 db + 2 bulk tools"
)
@pytest.mark.asyncio
async def test_plugin_serialises_handler_response_to_json():
"""plugin.py wraps each handler response in json.dumps(..., indent=2)."""
from plugins.wordpress_specialist import WordPressSpecialistPlugin
plugin = WordPressSpecialistPlugin(
config={"url": "https://wp.example.com", "username": "u", "app_password": "p"},
)
plugin.pages.client.get = AsyncMock( # type: ignore[method-assign]
return_value={"installed": False, "version": None, "pro": False, "post_types": []}
)
serialised = await plugin.wp_elementor_detect()
assert isinstance(serialised, str)
assert json.loads(serialised) == {
"installed": False,
"version": None,
"pro": False,
"post_types": [],
}